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-03-06 05:55:52 +01:00
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-06 05:55:52 +01:00
enum EmptyStateViewState {
case none
case loading
case badNetwork
}
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-03-06 07:21:52 +01:00
let selectCategoryItem = CurrentValueSubject < CategoryPickerItem , Never > ( . all )
let searchText = CurrentValueSubject < String , Never > ( " " )
2021-03-05 15:50:20 +01:00
let indexedServers = CurrentValueSubject < [ Mastodon . Entity . Server ] , Never > ( [ ] )
2021-05-13 11:50:37 +02:00
let unindexedServers = CurrentValueSubject < [ Mastodon . Entity . Server ] ? , Never > ( [ ] ) // s e t n i l w h e n l o a d i n g
2021-03-06 05:55:52 +01:00
let viewWillAppear = PassthroughSubject < Void , 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-03-06 07:21:52 +01:00
let filteredIndexedServers = CurrentValueSubject < [ Mastodon . Entity . Server ] , Never > ( [ ] )
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-06 05:55:52 +01:00
let isAuthenticating = CurrentValueSubject < Bool , Never > ( false )
let isLoadingIndexedServers = CurrentValueSubject < Bool , Never > ( false )
let emptyStateViewState = CurrentValueSubject < EmptyStateViewState , Never > ( . none )
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 ( )
}
2021-04-09 05:05:10 +02:00
deinit {
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
}
}
extension MastodonPickServerViewModel {
2021-02-23 15:14:10 +01:00
private func configure ( ) {
2021-03-06 07:21:52 +01:00
Publishers . CombineLatest (
2021-05-13 11:50:37 +02:00
filteredIndexedServers ,
unindexedServers
2021-02-24 15:47:42 +01:00
)
2021-03-05 15:50:20 +01:00
. receive ( on : DispatchQueue . main )
2021-03-06 07:21:52 +01:00
. sink ( receiveValue : { [ weak self ] indexedServers , unindexedServers in
2021-03-05 15:50:20 +01:00
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 {
2021-03-06 06:29:45 +01:00
let attribute = oldSnapshotServerItemAttributeDict [ server . domain ] ? ? PickServerItem . ServerItemAttribute ( isLast : false , isExpand : false )
attribute . isLast = false
2021-03-05 15:50:20 +01:00
let item = PickServerItem . server ( server : server , attribute : attribute )
2021-03-06 07:21:52 +01:00
guard ! serverItems . contains ( item ) else { continue }
serverItems . append ( item )
}
2021-05-13 11:50:37 +02:00
if let unindexedServers = unindexedServers {
if ! unindexedServers . isEmpty {
for server in unindexedServers {
let attribute = oldSnapshotServerItemAttributeDict [ server . domain ] ? ? PickServerItem . ServerItemAttribute ( isLast : false , isExpand : false )
attribute . isLast = false
let item = PickServerItem . server ( server : server , attribute : attribute )
guard ! serverItems . contains ( item ) else { continue }
serverItems . append ( item )
}
} else {
if indexedServers . isEmpty && ! self . isLoadingIndexedServers . value {
serverItems . append ( . loader ( attribute : PickServerItem . LoaderItemAttribute ( isLast : false , isEmptyResult : true ) ) )
}
}
} else {
serverItems . append ( . loader ( attribute : PickServerItem . LoaderItemAttribute ( isLast : false , isEmptyResult : false ) ) )
2021-02-25 07:09:19 +01:00
}
2021-05-13 11:50:37 +02:00
2021-03-06 06:29:45 +01:00
if case let . server ( _ , attribute ) = serverItems . last {
attribute . isLast = true
}
2021-05-13 11:50:37 +02:00
if case let . loader ( attribute ) = serverItems . last {
attribute . isLast = true
}
2021-03-05 15:50:20 +01:00
snapshot . appendItems ( serverItems , toSection : . servers )
2021-02-25 07:09:19 +01:00
2021-03-06 07:21:52 +01:00
diffableDataSource . defaultRowAnimation = . fade
diffableDataSource . apply ( snapshot , animatingDifferences : true , completion : nil )
2021-03-05 15:50:20 +01:00
} )
2021-02-24 15:47:42 +01:00
. store ( in : & disposeBag )
2021-03-06 05:55:52 +01:00
isLoadingIndexedServers
. map { isLoadingIndexedServers -> EmptyStateViewState in
if isLoadingIndexedServers {
return . loading
} else {
return . none
}
}
. assign ( to : \ . value , on : emptyStateViewState )
. store ( in : & disposeBag )
2021-03-05 15:50:20 +01:00
2021-03-06 07:21:52 +01:00
Publishers . CombineLatest3 (
indexedServers . eraseToAnyPublisher ( ) ,
selectCategoryItem . eraseToAnyPublisher ( ) ,
searchText . debounce ( for : . milliseconds ( 300 ) , scheduler : DispatchQueue . main ) . removeDuplicates ( )
)
. map { indexedServers , selectCategoryItem , searchText -> [ Mastodon . Entity . Server ] in
// F i l t e r t h e i n d e x e d s e r v e r s f r o m j o i n m a s t o d o n . o r g
switch selectCategoryItem {
case . all :
return MastodonPickServerViewModel . filterServers ( servers : indexedServers , category : nil , searchText : searchText )
case . category ( let category ) :
return MastodonPickServerViewModel . filterServers ( servers : indexedServers , category : category . category . rawValue , searchText : searchText )
}
}
. assign ( to : \ . value , on : filteredIndexedServers )
. store ( in : & disposeBag )
2021-02-24 15:47:42 +01:00
2021-03-06 07:21:52 +01:00
searchText
. debounce ( for : . milliseconds ( 300 ) , scheduler : DispatchQueue . main )
. removeDuplicates ( )
. compactMap { [ weak self ] searchText -> AnyPublisher < Result < Mastodon . Response . Content < [ Mastodon . Entity . Server ] > , Error > , Never > ? in
// 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
guard let self = self else { return nil }
guard let domain = AuthenticationViewModel . parseDomain ( from : searchText ) else {
return Just ( Result . failure ( APIService . APIError . implicit ( . badRequest ) ) ) . eraseToAnyPublisher ( )
}
2021-05-13 11:50:37 +02:00
self . unindexedServers . value = nil
2021-03-06 07:21:52 +01:00
return self . context . apiService . instance ( domain : domain )
. map { response -> Result < Mastodon . Response . Content < [ Mastodon . Entity . Server ] > , Error > in
let newResponse = response . map { [ Mastodon . Entity . Server ( instance : $0 ) ] }
return Result . success ( newResponse )
}
. catch { error in
return Just ( Result . failure ( error ) )
}
. eraseToAnyPublisher ( )
}
. switchToLatest ( )
. sink ( receiveValue : { [ weak self ] result in
guard let self = self else { return }
switch result {
case . success ( let response ) :
self . unindexedServers . send ( response . value )
2021-05-13 11:50:37 +02:00
case . failure ( let error ) :
if let error = error as ? APIService . APIError ,
case let . implicit ( reason ) = error ,
case . badRequest = reason {
self . unindexedServers . send ( [ ] )
} else {
self . unindexedServers . send ( nil )
}
2021-03-06 07:21:52 +01:00
}
} )
. store ( in : & disposeBag )
2021-02-24 15:47:42 +01:00
}
2021-03-06 07:21:52 +01:00
2021-02-23 15:14:10 +01:00
}
2021-03-06 07:21:52 +01:00
extension MastodonPickServerViewModel {
private static func filterServers ( servers : [ Mastodon . Entity . Server ] , category : String ? , searchText : String ) -> [ Mastodon . Entity . Server ] {
return servers
// 1 . F i l t e r t h e c a t e g o r y
. filter {
guard let category = category else { return true }
return $0 . category . caseInsensitiveCompare ( category ) = = . orderedSame
}
// 2 . F i l t e r t h e s e a r c h T e x t
. filter {
let searchText = searchText . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! searchText . isEmpty else {
return true
}
return $0 . domain . lowercased ( ) . contains ( searchText . lowercased ( ) )
}
}
}
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
}
}