From dde23757895b5cd3eca60efa358f2c8fa70a8fcb Mon Sep 17 00:00:00 2001 From: Dwayne Harris Date: Mon, 22 Oct 2018 01:25:00 -0700 Subject: [PATCH] Development --- elpha-ios.xcodeproj/project.pbxproj | 110 +---- elpha-ios/AccountTableViewController.swift | 160 ++++--- elpha-ios/AuthenticateViewController.swift | 83 ++-- elpha-ios/AuthenticationManager.swift | 27 +- elpha-ios/Base.lproj/Main.storyboard | 1 + elpha-ios/Configuration.swift | 2 + .../Elpha.xcdatamodel/contents | 56 +-- elpha-ios/InstanceRequest.swift | 9 + elpha-ios/InstancesDataManager.swift | 47 +- elpha-ios/MainTabBarController.swift | 2 +- elpha-ios/MastodonAPI.swift | 319 +++++++++++++ elpha-ios/MastodonDataManager.swift | 421 +++++++++++------- elpha-ios/StatusTableViewController.swift | 85 ++-- elpha-ios/StatusView.swift | 8 +- elpha-ios/TimelineTableViewController.swift | 227 ++++++---- elpha-ios/TimelinesTableViewController.swift | 2 +- 16 files changed, 962 insertions(+), 597 deletions(-) create mode 100644 elpha-ios/InstanceRequest.swift create mode 100644 elpha-ios/MastodonAPI.swift diff --git a/elpha-ios.xcodeproj/project.pbxproj b/elpha-ios.xcodeproj/project.pbxproj index 03cf848..e2f6a4c 100644 --- a/elpha-ios.xcodeproj/project.pbxproj +++ b/elpha-ios.xcodeproj/project.pbxproj @@ -14,13 +14,13 @@ 151AD4D9216899AD00F07403 /* AlamofireImage.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 1517EA842159D72200DE80D6 /* AlamofireImage.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 151AD4DD216899E000F07403 /* OAuthSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15A79B13215B438C007A326E /* OAuthSwift.framework */; }; 151AD4DE216899E000F07403 /* OAuthSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 15A79B13215B438C007A326E /* OAuthSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 151AD4E1216899F900F07403 /* MastodonKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15A79B02215B3CC5007A326E /* MastodonKit.framework */; }; - 151AD4E2216899F900F07403 /* MastodonKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 15A79B02215B3CC5007A326E /* MastodonKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 151AD4E621689A0F00F07403 /* Alamofire.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 157405C3215890BC00EEAAEB /* Alamofire.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 156FF015217289380074D9CA /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156FF014217289380074D9CA /* AccountTableViewCell.swift */; }; 156FF0312174797E0074D9CA /* StatusTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156FF0302174797E0074D9CA /* StatusTableViewController.swift */; }; 156FF04F2175CDBC0074D9CA /* MainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156FF04E2175CDBC0074D9CA /* MainStatusTableViewCell.swift */; }; 156FF051217683270074D9CA /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156FF050217683270074D9CA /* StatusTableViewCell.swift */; }; + 156FF07021779C570074D9CA /* MastodonAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156FF06F21779C570074D9CA /* MastodonAPI.swift */; }; + 156FF07221779C650074D9CA /* InstanceRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156FF07121779C650074D9CA /* InstanceRequest.swift */; }; 157405A82150588A00EEAAEB /* InstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157405A72150588A00EEAAEB /* InstanceViewController.swift */; }; 157405B12151A5DA00EEAAEB /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 157405AF2151A5DA00EEAAEB /* README.md */; }; 157405B42151A93E00EEAAEB /* InstancesDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157405B32151A93E00EEAAEB /* InstancesDataManager.swift */; }; @@ -124,13 +124,6 @@ remoteGlobalIDString = F43502791A6791B200038A29; remoteInfo = OAuthSwift; }; - 151AD4E3216899F900F07403 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */; - proxyType = 1; - remoteGlobalIDString = 629A4ACC213AF5B100A6386E; - remoteInfo = "MastodonKit-iOS"; - }; 151AD4E721689A0F00F07403 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 157405B7215890BC00EEAAEB /* Alamofire.xcodeproj */; @@ -187,34 +180,6 @@ remoteGlobalIDString = E4202FE01B667AA100C997FB; remoteInfo = "Alamofire watchOS"; }; - 15A79AFF215B3CC5007A326E /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = "MastodonKit::MastodonKit::Product"; - remoteInfo = MastodonKit; - }; - 15A79B01215B3CC5007A326E /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = 629A4B0E213AF5B100A6386E; - remoteInfo = "MastodonKit-iOS"; - }; - 15A79B03215B3CC5007A326E /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = 62864F50213AF68900823C8A; - remoteInfo = "MastodonKit-tvOS"; - }; - 15A79B05215B3CC5007A326E /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = "MastodonKit::MastodonKitTests::Product"; - remoteInfo = MastodonKitTests; - }; 15A79B12215B438C007A326E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 15A79B08215B438C007A326E /* OAuthSwift.xcodeproj */; @@ -275,7 +240,6 @@ files = ( 151AD4DE216899E000F07403 /* OAuthSwift.framework in Embed Frameworks */, 151AD4D9216899AD00F07403 /* AlamofireImage.framework in Embed Frameworks */, - 151AD4E2216899F900F07403 /* MastodonKit.framework in Embed Frameworks */, 151AD4E621689A0F00F07403 /* Alamofire.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -293,6 +257,8 @@ 156FF0302174797E0074D9CA /* StatusTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewController.swift; sourceTree = ""; }; 156FF04E2175CDBC0074D9CA /* MainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStatusTableViewCell.swift; sourceTree = ""; }; 156FF050217683270074D9CA /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; + 156FF06F21779C570074D9CA /* MastodonAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAPI.swift; sourceTree = ""; }; + 156FF07121779C650074D9CA /* InstanceRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceRequest.swift; sourceTree = ""; }; 157405A72150588A00EEAAEB /* InstanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceViewController.swift; sourceTree = ""; }; 157405AF2151A5DA00EEAAEB /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 157405B32151A93E00EEAAEB /* InstancesDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesDataManager.swift; sourceTree = ""; }; @@ -317,7 +283,6 @@ 15960E7D21329FED00C38CE9 /* AuthenticateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticateViewController.swift; sourceTree = ""; }; 15960E812136668500C38CE9 /* TimelinesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesNavigationController.swift; sourceTree = ""; }; 15960E83213774FC00C38CE9 /* InstancesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesTableViewController.swift; sourceTree = ""; }; - 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = MastodonKit.xcodeproj; path = Frameworks/MastodonKit/MastodonKit.xcodeproj; sourceTree = ""; }; 15A79B08215B438C007A326E /* OAuthSwift.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = OAuthSwift.xcodeproj; path = Frameworks/OAuthSwift/OAuthSwift.xcodeproj; sourceTree = ""; }; 15A79B42215EB959007A326E /* CoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManager.swift; sourceTree = ""; }; 15BB72A82171A6BE002F1FA4 /* TimelinesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesTableViewController.swift; sourceTree = ""; }; @@ -335,7 +300,6 @@ files = ( 15A79B2E215C63B6007A326E /* AlamofireImage.framework in Frameworks */, 151AD4DD216899E000F07403 /* OAuthSwift.framework in Frameworks */, - 151AD4E1216899F900F07403 /* MastodonKit.framework in Frameworks */, 157405D1215890D700EEAAEB /* Alamofire.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -411,6 +375,15 @@ name = Extensions; sourceTree = ""; }; + 156FF05621779C140074D9CA /* API */ = { + isa = PBXGroup; + children = ( + 156FF07121779C650074D9CA /* InstanceRequest.swift */, + 156FF06F21779C570074D9CA /* MastodonAPI.swift */, + ); + name = API; + sourceTree = ""; + }; 157405B8215890BC00EEAAEB /* Products */ = { isa = PBXGroup; children = ( @@ -438,7 +411,6 @@ 157405AF2151A5DA00EEAAEB /* README.md */, 157405B7215890BC00EEAAEB /* Alamofire.xcodeproj */, 1517EA6F2159D72200DE80D6 /* AlamofireImage.xcodeproj */, - 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */, 15A79B08215B438C007A326E /* OAuthSwift.xcodeproj */, 15960E59213145E100C38CE9 /* elpha-ios */, 15960E58213145E100C38CE9 /* Products */, @@ -457,11 +429,12 @@ 15960E59213145E100C38CE9 /* elpha-ios */ = { isa = PBXGroup; children = ( - 15960E68213145E200C38CE9 /* Info.plist */, 15960E5A213145E100C38CE9 /* AppDelegate.swift */, - 15960E7621322C6F00C38CE9 /* Configuration.swift */, 15960E63213145E200C38CE9 /* Assets.xcassets */, + 15960E7621322C6F00C38CE9 /* Configuration.swift */, 15960E6E21321FA500C38CE9 /* Elpha.xcdatamodeld */, + 15960E68213145E200C38CE9 /* Info.plist */, + 156FF05621779C140074D9CA /* API */, 151AD4AF2166DDA000F07403 /* Extensions */, 15960E7121322B9F00C38CE9 /* Keychain Helper */, 151AD4AC2166DD0200F07403 /* Managers */, @@ -500,17 +473,6 @@ name = "View Controllers"; sourceTree = ""; }; - 15A79AE8215B3CC5007A326E /* Products */ = { - isa = PBXGroup; - children = ( - 15A79B00215B3CC5007A326E /* MastodonKit.framework */, - 15A79B02215B3CC5007A326E /* MastodonKit.framework */, - 15A79B04215B3CC5007A326E /* MastodonKit.framework */, - 15A79B06215B3CC5007A326E /* MastodonKitTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; 15A79B09215B438C007A326E /* Products */ = { isa = PBXGroup; children = ( @@ -542,7 +504,6 @@ dependencies = ( 151AD4DB216899AD00F07403 /* PBXTargetDependency */, 151AD4E0216899E000F07403 /* PBXTargetDependency */, - 151AD4E4216899F900F07403 /* PBXTargetDependency */, 151AD4E821689A0F00F07403 /* PBXTargetDependency */, ); name = "elpha-ios"; @@ -586,10 +547,6 @@ ProductGroup = 1517EA702159D72200DE80D6 /* Products */; ProjectRef = 1517EA6F2159D72200DE80D6 /* AlamofireImage.xcodeproj */; }, - { - ProductGroup = 15A79AE8215B3CC5007A326E /* Products */; - ProjectRef = 15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */; - }, { ProductGroup = 15A79B09215B438C007A326E /* Products */; ProjectRef = 15A79B08215B438C007A326E /* OAuthSwift.xcodeproj */; @@ -708,34 +665,6 @@ remoteRef = 157405CE215890BC00EEAAEB /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; - 15A79B00215B3CC5007A326E /* MastodonKit.framework */ = { - isa = PBXReferenceProxy; - fileType = wrapper.framework; - path = MastodonKit.framework; - remoteRef = 15A79AFF215B3CC5007A326E /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; - 15A79B02215B3CC5007A326E /* MastodonKit.framework */ = { - isa = PBXReferenceProxy; - fileType = wrapper.framework; - path = MastodonKit.framework; - remoteRef = 15A79B01215B3CC5007A326E /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; - 15A79B04215B3CC5007A326E /* MastodonKit.framework */ = { - isa = PBXReferenceProxy; - fileType = wrapper.framework; - path = MastodonKit.framework; - remoteRef = 15A79B03215B3CC5007A326E /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; - 15A79B06215B3CC5007A326E /* MastodonKitTests.xctest */ = { - isa = PBXReferenceProxy; - fileType = wrapper.cfbundle; - path = MastodonKitTests.xctest; - remoteRef = 15A79B05215B3CC5007A326E /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; 15A79B13215B438C007A326E /* OAuthSwift.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; @@ -822,8 +751,10 @@ 15960E7E21329FED00C38CE9 /* AuthenticateViewController.swift in Sources */, 15960E5B213145E100C38CE9 /* AppDelegate.swift in Sources */, 15131EF2216D8D570092B252 /* StatusView.swift in Sources */, + 156FF07221779C650074D9CA /* InstanceRequest.swift in Sources */, 15BB72A92171A6BE002F1FA4 /* TimelinesTableViewController.swift in Sources */, 15960E7721322C6F00C38CE9 /* Configuration.swift in Sources */, + 156FF07021779C570074D9CA /* MastodonAPI.swift in Sources */, 15C91A04216AB32500D97DC3 /* NewStatusesView.swift in Sources */, 15131EF6216DBA820092B252 /* AccountNavigationController.swift in Sources */, 15960E7521322BF800C38CE9 /* KeychainWrapper.swift in Sources */, @@ -854,11 +785,6 @@ name = OAuthSwift; targetProxy = 151AD4DF216899E000F07403 /* PBXContainerItemProxy */; }; - 151AD4E4216899F900F07403 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = "MastodonKit-iOS"; - targetProxy = 151AD4E3216899F900F07403 /* PBXContainerItemProxy */; - }; 151AD4E821689A0F00F07403 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = "Alamofire iOS"; diff --git a/elpha-ios/AccountTableViewController.swift b/elpha-ios/AccountTableViewController.swift index 4570415..dbc792f 100644 --- a/elpha-ios/AccountTableViewController.swift +++ b/elpha-ios/AccountTableViewController.swift @@ -8,7 +8,6 @@ import AlamofireImage import CoreData -import MastodonKit import UIKit class AccountTableViewController: UITableViewController { @@ -24,9 +23,7 @@ class AccountTableViewController: UITableViewController { @IBOutlet var statusTypeSegmentedControl: UISegmentedControl! let fetchLimit = 20 - var fetchedResultsController: NSFetchedResultsController? = nil - var account: AccountMO? = nil var loading: Bool = false { @@ -41,6 +38,19 @@ class AccountTableViewController: UITableViewController { } } + var currentPaginationContext: String { + guard let account = self.account else { + return "" + } + + switch statusTypeSegmentedControl.selectedSegmentIndex { + case 3: + return "timeline:favorites" + case let index: + return "account:\(account.acct!):\(index)" + } + } + @IBAction func statusTypeChanged(_ sender: UISegmentedControl) { initializeFetchedResultsController() fetch() @@ -92,7 +102,7 @@ class AccountTableViewController: UITableViewController { refreshControl?.addTarget(self, action: #selector(self.fetch), for: .valueChanged) if self.account == nil { - if let session = AuthenticationManager.shared.session { + if let session = AuthenticationManager.session { self.account = session.account } } @@ -102,7 +112,7 @@ class AccountTableViewController: UITableViewController { initializeFetchedResultsController() } - if let session = AuthenticationManager.shared.session { + if let session = AuthenticationManager.session { if session.account == account { statusTypeSegmentedControl.insertSegment(withTitle: "Favorites", at: statusTypeSegmentedControl.numberOfSegments, animated: true) } @@ -113,22 +123,17 @@ class AccountTableViewController: UITableViewController { super.viewDidAppear(animated) if let account = self.account { - if let client = AuthenticationManager.shared.mkClient { - let request = Accounts.account(id: account.id!) + MastodonAPI.account(id: account.id!) { data, error in + guard let data = data, error == nil else { + return + } - client.run(request) { result in - switch result { - case .success(let remoteAccount, _): - self.account = MastodonDataManager.upsertAccount(remoteAccount) - - if let account = self.account { - DispatchQueue.main.async { - self.updateHeader(withAccount: account) - self.fetch() - } - } - case .failure(let error): - print("\(error)") + self.account = MastodonDataManager.upsertAccount(data) + + if let account = self.account { + DispatchQueue.main.async { + self.updateHeader(withAccount: account) + self.fetch() } } } @@ -136,49 +141,87 @@ class AccountTableViewController: UITableViewController { } @objc func fetch() { - fetchStatuses(withRange: .limit(fetchLimit)) { statuses, error in + fetchStatuses { error in if error != nil { print("\(String(describing: error))") } } } - func fetchStatuses(withRange requestRange: RequestRange, completion: @escaping ([UpsertResult], Error?) -> Void) { - guard let client = AuthenticationManager.shared.mkClient else { - completion([], nil) - return - } - + func fetchStatuses(withPagination pagination: PaginationItem? = nil, completion: @escaping (Error?) -> Void) { if let account = account { - var request: Request<[Status]> + func requestCompletion(data: [JSONObject]?, pagination: [PaginationItem]?, error: Error?) { + guard let data = data, error == nil else { + completion(error) + return + } + + for (index, status) in data.enumerated() { + if let upsertResult = MastodonDataManager.upsertStatus(status) { + let status = upsertResult.object + + if let pagination = pagination { + var markers: [PaginationMarker] = status.markers ?? [] + + if index == 0 { + pagination.forEach { item in + if item.direction == .prev { + markers.append(PaginationMarker(context: self.currentPaginationContext, item: item)) + } + } + } + + if index == data.count - 1 { + pagination.forEach { item in + if item.direction == .next { + markers.append(PaginationMarker(context: self.currentPaginationContext, item: item)) + } + } + } + } + } + } + + CoreDataManager.shared.saveContext() + + self.loading = false + completion(nil) + } switch statusTypeSegmentedControl.selectedSegmentIndex { case 1: - request = Accounts.statuses(id: account.id!, mediaOnly: false, pinnedOnly: false, excludeReplies: false, range: requestRange) + MastodonAPI.statuses( + accountID: account.id!, + onlyMedia: false, + excludeReplies: false, + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) case 2: - request = Accounts.statuses(id: account.id!, mediaOnly: true, pinnedOnly: false, excludeReplies: true, range: requestRange) + MastodonAPI.statuses( + accountID: account.id!, + onlyMedia: true, + excludeReplies: true, + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) case 3: - request = Favourites.all(range: requestRange) + MastodonAPI.favorites( + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) default: - request = Accounts.statuses(id: account.id!, mediaOnly: false, pinnedOnly: false, excludeReplies: true, range: requestRange) - } - - client.run(request) { result in - switch result { - case .success(let remoteStatuses, _): - DispatchQueue.main.async { - let statuses = remoteStatuses.compactMap { status in - return MastodonDataManager.upsertStatus(status) - } - - CoreDataManager.shared.saveContext() - - self.loading = false - completion(statuses, nil) - } - case .failure(let error): - completion([], error) - } + MastodonAPI.statuses( + accountID: account.id!, + onlyMedia: false, + excludeReplies: true, + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) } } } @@ -199,7 +242,7 @@ extension AccountTableViewController: NSFetchedResultsControllerDelegate { case 2: request.predicate = NSPredicate(format: "account == %@ AND attachments.@count > 0", account) case 3: - request.predicate = NSPredicate(format: "favourited == YES") + request.predicate = NSPredicate(format: "favorited == YES") default: request.predicate = NSPredicate(format: "account == %@ AND inReplyToID == nil", account) } @@ -249,17 +292,10 @@ extension AccountTableViewController: NSFetchedResultsControllerDelegate { cell.statusView.delegate = self cell.statusView.update(withStatus: status) - if !loading { - let statusAge = Calendar.current.dateComponents([.minute], from: status.fetchedAt!, to: Date()) - let stale = statusAge.minute! > 30 - let first = indexPath.row == 0 - let last = indexPath.row == fetchedResultsController?.fetchedObjects?.count ?? 0 - 1 - - if (!first && stale) || last { - fetchStatuses(withRange: .max(id: status.id!, limit: fetchLimit)) { statuses, error in - if error != nil { - print("\(String(describing: error))") - } + if let markers = status.markers { + markers.forEach { marker in + if marker.context == self.currentPaginationContext { + print("Found marker: \(marker)") } } } diff --git a/elpha-ios/AuthenticateViewController.swift b/elpha-ios/AuthenticateViewController.swift index d54a1f8..fc72652 100644 --- a/elpha-ios/AuthenticateViewController.swift +++ b/elpha-ios/AuthenticateViewController.swift @@ -8,7 +8,6 @@ import Alamofire import CoreData -import MastodonKit import OAuthSwift import UIKit @@ -61,8 +60,8 @@ class AuthenticateViewController: UIViewController { let oauthswift = OAuth2Swift( consumerKey: client.clientID!, consumerSecret: client.clientSecret!, - authorizeUrl: "https://\(client.url!)/oauth/authorize", - accessTokenUrl: "https://\(client.url!)/oauth/token", + authorizeUrl: "https://\(client.host!)/oauth/authorize", + accessTokenUrl: "https://\(client.host!)/oauth/token", responseType: "code" ) @@ -74,21 +73,19 @@ class AuthenticateViewController: UIViewController { scope: "read write follow", state: NSUUID().uuidString, success: { credential, _, _ in - let mkClient = Client( - baseURL: "https://\(client.url!)", - accessToken: credential.oauthToken - ) + let token = credential.oauthToken + let serverURL = URL(string: "https://\(client.host!)") - let request = Accounts.currentUser() - mkClient.run(request) { result in - switch result { - case .success(let account, _): - let account = MastodonDataManager.upsertAccount(account) - let _ = AuthenticationManager.shared.saveSession(client: client, account: account!, token: credential.oauthToken) - self.dismiss(animated: true) - case .failure(let error): - print("\(error)") + MastodonAPI.currentUser(token: token, serverURL: serverURL!) { data, error in + guard let data = data, error == nil else { + print("\(String(describing: error))") + return } + + let account = MastodonDataManager.upsertAccount(data) + _ = AuthenticationManager.saveSession(client: client, account: account!, token: credential.oauthToken) + + self.dismiss(animated: true) } }, failure: { error in @@ -98,21 +95,24 @@ class AuthenticateViewController: UIViewController { } @IBAction func signIn(_ sender: Any) { - guard var url = instanceTextField.text, !url.isEmpty else { + guard var host = instanceTextField.text, !host.isEmpty else { return } - if url.contains("@") { - if let urlPart = url.split(separator: "@").last { - url = String(urlPart) + if host.contains("@") { + if let urlPart = host.split(separator: "@").last { + host = String(urlPart) } else { return } } - if url.starts(with: "http") { - let regex = try! NSRegularExpression(pattern: "https?://") - url = regex.stringByReplacingMatches(in: url, options: [], range: NSMakeRange(0, url.count), withTemplate: "") + if host.starts(with: "https://") { + host = String(host.dropFirst(8)) + } + + if host.starts(with: "http://") { + host = String(host.dropFirst(7)) } signInButton.isEnabled = false @@ -121,36 +121,27 @@ class AuthenticateViewController: UIViewController { } let request = NSFetchRequest(entityName: "Client") - request.predicate = NSPredicate(format: "url == %@", url) + request.predicate = NSPredicate(format: "host == %@", host) do { let response = try CoreDataManager.shared.context.fetch(request) if let client = response.first { self.authorize(client: client) } else { - let client = Client(baseURL: "https://\(url)") - - let request = Clients.register( - clientName: Config.clientDisplayName, - redirectURI: "elpha://oauth", - scopes: [.read, .write, .follow], - website: Config.clientWebsite - ) - - client.run(request) { result in - switch result { - case .success(let remoteClient, _): - let client = MastodonDataManager.saveClient( - id: remoteClient.id, - clientID: remoteClient.clientID, - clientSecret: remoteClient.clientSecret, - url: url - ) - - self.authorize(client: client) - case .failure(let error): - print("\(error)") + MastodonAPI.registerApp(serverURL: URL(string: "https://\(host)")!) { data, error in + guard let data = data, error == nil else { + print("\(String(describing: error))") + return } + + let client = MastodonDataManager.saveClient( + id: data["id"] as! String, + clientID: data["client_id"] as! String, + clientSecret: data["client_secret"] as! String, + host: host + ) + + self.authorize(client: client) } } } catch { diff --git a/elpha-ios/AuthenticationManager.swift b/elpha-ios/AuthenticationManager.swift index b27514c..234d506 100644 --- a/elpha-ios/AuthenticationManager.swift +++ b/elpha-ios/AuthenticationManager.swift @@ -7,13 +7,10 @@ // import CoreData -import MastodonKit import UIKit class AuthenticationManager { - static let shared = AuthenticationManager() - - var sessions: [SessionMO] { + static var sessions: [SessionMO] { get { do { let request = NSFetchRequest(entityName: "Session") @@ -25,7 +22,7 @@ class AuthenticationManager { } } - var session: SessionMO? { + static var session: SessionMO? { get { let request = NSFetchRequest(entityName: "Session") request.predicate = NSPredicate(format: "selected = YES") @@ -40,27 +37,17 @@ class AuthenticationManager { } } - var token: String? { - get { - guard let account = session?.account else { - return nil - } - - return KeychainWrapper.standard.string(forKey: "token:\(String(describing: account.acct))") - } - } - - var mkClient: Client? { + static var token: String? { get { - guard let client = session?.client else { + guard let account = session?.account, let client = session?.client else { return nil } - return Client(baseURL: "https://\(client.url!)", accessToken: token) + return KeychainWrapper.standard.string(forKey: "token:\(account.username!)@\(client.host!)") } } - func saveSession(client: ClientMO, account: AccountMO, token: String) -> SessionMO? { + static func saveSession(client: ClientMO, account: AccountMO, token: String) -> SessionMO? { if let session = self.session { session.selected = false } @@ -73,7 +60,7 @@ class AuthenticationManager { session.selected = true CoreDataManager.shared.saveContext() - KeychainWrapper.standard.set(token, forKey: "token:\(String(describing: account.acct))") + KeychainWrapper.standard.set(token, forKey: "token:\(account.username!)@\(client.host!)") return session } diff --git a/elpha-ios/Base.lproj/Main.storyboard b/elpha-ios/Base.lproj/Main.storyboard index b2ce7c8..d6f0c14 100644 --- a/elpha-ios/Base.lproj/Main.storyboard +++ b/elpha-ios/Base.lproj/Main.storyboard @@ -976,6 +976,7 @@ + diff --git a/elpha-ios/Configuration.swift b/elpha-ios/Configuration.swift index 999a9d9..0c96578 100644 --- a/elpha-ios/Configuration.swift +++ b/elpha-ios/Configuration.swift @@ -18,4 +18,6 @@ struct Config { static let instancesServiceRandomEndpoint = "/api/1.0/instances/sample" static let instancesServiceApplicationID = "104969750" static let instancesServiceSecret = "xEGMUQL9w2tYU7CnkBMY555nmLYL1dryCdQNFZWQlwHqDg0cRPxF5nXx34LVM5Zt8CbvmNnj89nruMYe0jCzlky0lolAcoLOcNrNerh9m30Q85WskiOqEkHp0M5HhEeS" + + static let logRequests = true } diff --git a/elpha-ios/Elpha.xcdatamodeld/Elpha.xcdatamodel/contents b/elpha-ios/Elpha.xcdatamodeld/Elpha.xcdatamodel/contents index be1a602..05f0bfb 100644 --- a/elpha-ios/Elpha.xcdatamodeld/Elpha.xcdatamodel/contents +++ b/elpha-ios/Elpha.xcdatamodeld/Elpha.xcdatamodel/contents @@ -8,6 +8,7 @@ + @@ -17,9 +18,9 @@ - + - + @@ -33,8 +34,8 @@ - - + + @@ -42,19 +43,10 @@ - + - - - - - - - - - @@ -70,6 +62,7 @@ + @@ -101,14 +94,6 @@ - - - - - - - - @@ -121,13 +106,16 @@ - - + + + + + @@ -141,8 +129,6 @@ - - @@ -154,25 +140,25 @@ - - + + + + - + - - + - + - - + - + \ No newline at end of file diff --git a/elpha-ios/InstanceRequest.swift b/elpha-ios/InstanceRequest.swift new file mode 100644 index 0000000..b0c2926 --- /dev/null +++ b/elpha-ios/InstanceRequest.swift @@ -0,0 +1,9 @@ +// +// InstanceRequest.swift +// elpha-ios +// +// Created by Dwayne Harris on 10/17/18. +// Copyright © 2018 Elpha. All rights reserved. +// + +import Foundation diff --git a/elpha-ios/InstancesDataManager.swift b/elpha-ios/InstancesDataManager.swift index c841146..5500fdd 100644 --- a/elpha-ios/InstancesDataManager.swift +++ b/elpha-ios/InstancesDataManager.swift @@ -64,7 +64,7 @@ class InstancesDataManager { } } - func setAttributes(on i: ISInstanceMO, with attributes: [String: Any]) { + func setAttributes(on instance: ISInstanceMO, with attributes: [String: Any]) { guard let id = attributes["id"] as? String, let name = attributes["name"] as? String else { print("Error") return @@ -74,49 +74,50 @@ class InstancesDataManager { dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" dateFormatter.locale = Locale(identifier: "en_US_POSIX") - i.id = id - i.name = name - i.url = URL(string: "https://\(name)")! - i.uptime = attributes["uptime"] as! Int32 - i.up = attributes["up"] as! Bool - i.dead = attributes["dead"] as! Bool - i.version = attributes["version"] as? String - i.ipv6 = attributes["ipv6"] as! Bool - i.users = Int64(attributes["users"] as! String)! - i.statuses = Int64(attributes["statuses"] as! String)! - i.connections = Int64(attributes["connections"] as! String)! - i.openRegistrations = attributes["open_registrations"] as! Bool + instance.id = id + instance.name = name + instance.url = URL(string: "https://\(name)")! + instance.uptime = attributes["uptime"] as! Int32 + instance.up = attributes["up"] as! Bool + instance.dead = attributes["dead"] as! Bool + instance.version = attributes["version"] as? String + instance.ipv6 = attributes["ipv6"] as! Bool + instance.users = Int64(attributes["users"] as! String)! + instance.statuses = Int64(attributes["statuses"] as! String)! + instance.connections = Int64(attributes["connections"] as! String)! + instance.openRegistrations = attributes["open_registrations"] as! Bool + instance.fetchedAt = Date() if let activeUsers = attributes["active_users"] as? Int64 { - i.activeUsers = activeUsers + instance.activeUsers = activeUsers } if let updatedAt = attributes["updated_at"] as? String { - i.updatedAt = dateFormatter.date(from: updatedAt) + instance.updatedAt = dateFormatter.date(from: updatedAt) } if let checkedAt = attributes["checked_at"] as? String { - i.checkedAt = dateFormatter.date(from: checkedAt) + instance.checkedAt = dateFormatter.date(from: checkedAt) } if let thumbnail = attributes["thumbnail"] as? String { - i.thumbnail = URL(string: thumbnail)! + instance.thumbnail = URL(string: thumbnail)! } if let info = attributes["info"] as? [String: Any] { - i.shortDesc = info["short_description"] as? String - i.fullDescription = info["full_description"] as? String - i.topic = info["topic"] as? String + instance.shortDesc = info["short_description"] as? String + instance.fullDescription = info["full_description"] as? String + instance.topic = info["topic"] as? String for language in info["languages"] as! [String] { if let l = upsertLanguage(string: language) { - i.mutableSetValue(forKey: "languages").add(l) + instance.mutableSetValue(forKey: "languages").add(l) } } for category in info["categories"] as! [String] { if let c = upsertCategory(string: category) { - i.mutableSetValue(forKey: "categories").add(c) + instance.mutableSetValue(forKey: "categories").add(c) } } } @@ -149,7 +150,7 @@ class InstancesDataManager { "sort_order=desc", "include_closed=true", "include_down=false", - ] + ] if let nextID = nextID { params.append("min_id=\(nextID)") diff --git a/elpha-ios/MainTabBarController.swift b/elpha-ios/MainTabBarController.swift index 3b2b932..747a13e 100644 --- a/elpha-ios/MainTabBarController.swift +++ b/elpha-ios/MainTabBarController.swift @@ -12,7 +12,7 @@ class MainTabBarController: UITabBarController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if AuthenticationManager.shared.sessions.count == 0 { + if AuthenticationManager.sessions.count == 0 { performSegue(withIdentifier: "AuthenticateSegue", sender: self) return } diff --git a/elpha-ios/MastodonAPI.swift b/elpha-ios/MastodonAPI.swift new file mode 100644 index 0000000..e3dab4a --- /dev/null +++ b/elpha-ios/MastodonAPI.swift @@ -0,0 +1,319 @@ +// +// MastodonAPI.swift +// elpha-ios +// +// Created by Dwayne Harris on 10/17/18. +// Copyright © 2018 Elpha. All rights reserved. +// + +import Alamofire +import Foundation + +typealias JSONObject = [String: Any] +typealias JSONArray = [Any] +typealias JSONObjectArray = [JSONObject] + +enum MastodonRequestError: Error { + case noResponse + case unauthenticated +} + +enum TimelineCategory: String { + case home, local, federated, tag, favorites +} + +enum PaginationDirection: String { + case prev, next +} + +public class PaginationItem: NSObject, NSCoding { + let direction: PaginationDirection + let statusID: String + + init(direction: PaginationDirection, statusID: String) { + self.direction = direction + self.statusID = statusID + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(direction.rawValue, forKey: "direction") + aCoder.encode(statusID, forKey: "statusID") + } + + convenience required public init?(coder aDecoder: NSCoder) { + guard + let direction = PaginationDirection(rawValue: aDecoder.decodeObject(forKey: "direction") as! String), + let statusID = aDecoder.decodeObject(forKey: "statusID") as? String + else { + return nil + } + + self.init(direction: direction, statusID: statusID) + } +} + +class MastodonAPI { + static var serverURL: URL? { + guard let host = AuthenticationManager.session?.client?.host else { + return nil + } + + return URL(string: "https://\(host)") + } + + static func parseLinkHeader(_ link: String?) -> [PaginationItem] { + guard let link = link else { + return [] + } + + let regex = try! NSRegularExpression(pattern: "<.*\\?(?:max_id|since_id)=([0-9]+)>;rel=\"(next|prev)\"", options: .caseInsensitive) + let matches = regex.matches(in: link, options: [], range: NSRange(location: 0, length: link.count)) + return matches.map { match in + let statusRange = match.range(at: 0) + let directionRange = match.range(at: 1) + + let statusID = link[Range(statusRange, in: link)!] + let direction = link[Range(directionRange, in: link)!] + + return PaginationItem(direction: PaginationDirection(rawValue: String(direction))!, statusID: String(statusID)) + } + } + + private static func request( + token: String, + serverURL: URL, + path: String, + method: HTTPMethod = .get, + parameters: Parameters? = nil, + pagination: PaginationItem? = nil, + completion: @escaping (Any?, [PaginationItem]?, Error?) -> Void + ) { + let requestURL = serverURL.appendingPathComponent(path) + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(token)", + "Accept": "application/json", + ] + + if Config.logRequests { + print("Request: \(requestURL.absoluteString)") + } + + var parameters = parameters ?? [:] + + if let pagination = pagination { + switch pagination.direction { + case .prev: + parameters["since_id"] = pagination.statusID + case .next: + parameters["max_id"] = pagination.statusID + } + } + + Alamofire.request( + requestURL, + method: method, + parameters: parameters, + encoding: URLEncoding.default, + headers: headers + ).validate().responseJSON { response in + switch response.result { + case .success(let data): + let pagination = self.parseLinkHeader(response.response?.allHeaderFields["Link"] as? String) + completion(data, pagination, nil) + case .failure(let error): + completion(nil, nil, error) + } + } + } + + private static func request( + path: String, + method: HTTPMethod = .get, + parameters: Parameters? = nil, + pagination: PaginationItem? = nil, + completion: @escaping (Any?, [PaginationItem]?, Error?) -> Void + ) { + guard let token = AuthenticationManager.token, let serverURL = self.serverURL else { + completion(nil, nil, MastodonRequestError.unauthenticated) + return + } + + request( + token: token, + serverURL: serverURL, + path: path, + method: method, + parameters: parameters, + pagination: pagination, + completion: completion + ) + } + + static func currentUser(token: String, serverURL: URL, completion: @escaping (JSONObject?, Error?) -> Void) { + self.request(token: token, serverURL: serverURL, path: "api/v1/accounts/verify_credentials") { data, _, error in + guard error == nil else { + completion(nil, error) + return + } + + completion(data as? JSONObject, nil) + } + } + + static func account(id: String, completion: @escaping (JSONObject?, Error?) -> Void) { + self.request(path: "api/v1/accounts/\(id)") { data, _, error in + guard error == nil else { + completion(nil, error) + return + } + + completion(data as? JSONObject, nil) + } + } + + static func status(id: String, completion: @escaping (JSONObject?, Error?) -> Void) { + self.request(path: "api/v1/statuses/\(id)") { data, _, error in + guard error == nil else { + completion(nil, error) + return + } + + completion(data as? JSONObject, nil) + } + } + + static func context(id: String, completion: @escaping ([String: Any]?, Error?) -> Void) { + self.request(path: "api/v1/statuses/\(id)/context") { data, _, error in + guard error == nil else { + completion(nil, error) + return + } + + completion(data as? [String: Any], nil) + } + } + + static func homeTimeline( + limit: Int?, + pagination: PaginationItem?, + completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void + ) { + let parameters: Parameters = ["limit": limit ?? 20] + + self.request(path: "api/v1/timelines/home", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in + guard error == nil else { + completion(nil, nil, error) + return + } + + completion(data as? JSONObjectArray, pagination, nil) + } + } + + static func publicTimeline( + local: Bool, + limit: Int?, + pagination: PaginationItem?, + completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void + ) { + let parameters: Parameters = [ + "local": local, + "limit": limit ?? 20, + ] + + self.request(path: "api/v1/timelines/home", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in + guard error == nil else { + completion(nil, nil, error) + return + } + + completion(data as? JSONObjectArray, pagination, nil) + } + } + + static func tagTimeline( + local: Bool, + limit: Int?, + pagination: PaginationItem?, + completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void + ) { + let parameters: Parameters = [ + "local": local, + "limit": limit ?? 20, + ] + + self.request(path: "api/v1/timelines/home", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in + guard error == nil else { + completion(nil, nil, error) + return + } + + completion(data as? JSONObjectArray, pagination, nil) + } + } + + static func statuses( + accountID: String, + onlyMedia: Bool, + excludeReplies: Bool, + limit: Int?, + pagination: PaginationItem?, + completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void + ) { + let parameters: Parameters = [ + "only_media": onlyMedia, + "exclude_replies": excludeReplies, + "limit": limit ?? 20, + ] + + self.request(path: "api/v1/accounts/\(accountID)/statuses", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in + guard error == nil else { + completion(nil, nil, error) + return + } + + completion(data as? JSONObjectArray, pagination, nil) + } + } + + static func favorites( + limit: Int?, + pagination: PaginationItem?, + completion: @escaping (JSONObjectArray?, [PaginationItem]?, Error?) -> Void + ) { + let parameters: Parameters = ["limit": limit ?? 20] + + self.request(path: "api/v1/favourites", method: .get, parameters: parameters, pagination: pagination) { data, pagination, error in + guard error == nil else { + completion(nil, nil, error) + return + } + + completion(data as? JSONObjectArray, pagination, nil) + } + } + + static func registerApp(serverURL: URL, completion: @escaping (JSONObject?, Error?) -> Void) { + let requestURL = serverURL.appendingPathComponent("api/v1/apps") + let parameters: Parameters = [ + "client_name": Config.clientDisplayName, + "redirect_uris": "elpha://oauth", + "scopes": "read write follow", + "website": Config.clientWebsite, + ] + + Alamofire.request( + requestURL, + method: .post, + parameters: parameters, + encoding: URLEncoding(destination: .httpBody) + ).validate().responseJSON { response in + switch response.result { + case .success(let data): + completion(data as? JSONObject, nil) + case .failure(let error): + completion(nil, error) + } + } + } +} diff --git a/elpha-ios/MastodonDataManager.swift b/elpha-ios/MastodonDataManager.swift index 2646454..1488c94 100644 --- a/elpha-ios/MastodonDataManager.swift +++ b/elpha-ios/MastodonDataManager.swift @@ -8,50 +8,186 @@ import CoreData import Foundation -import MastodonKit -class UpsertResult { - var model: T +class UpsertResult { + var object: T var new: Bool = false - init(model: T, new: Bool) { - self.model = model + init(object: T, new: Bool) { + self.object = object self.new = new } } +@objc public class AccountField: NSObject, NSCoding { + let name: String + let value: String + + init(name: String, value: String) { + self.name = name + self.value = value + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(name, forKey: "name") + aCoder.encode(value, forKey: "value") + } + + convenience required public init?(coder aDecoder: NSCoder) { + guard + let name = aDecoder.decodeObject(forKey: "name") as? String, + let value = aDecoder.decodeObject(forKey: "value") as? String + else { + return nil + } + + self.init(name: name, value: value) + } +} + +@objc public class Mention: NSObject, NSCoding { + let id: String + let url: URL + let username: String + let acct: String + + init(id: String, url: URL, username: String, acct: String) { + self.id = id + self.url = url + self.username = username + self.acct = acct + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(id, forKey: "id") + aCoder.encode(url, forKey: "url") + aCoder.encode(username, forKey: "username") + aCoder.encode(acct, forKey: "acct") + } + + convenience required public init?(coder aDecoder: NSCoder) { + guard + let id = aDecoder.decodeObject(forKey: "id") as? String, + let url = aDecoder.decodeObject(forKey: "url") as? URL, + let username = aDecoder.decodeObject(forKey: "username") as? String, + let acct = aDecoder.decodeObject(forKey: "acct") as? String + else { + return nil + } + + self.init(id: id, url: url, username: username, acct: acct) + } +} + +@objc public class Emoji: NSObject, NSCoding { + let shortcode: String + let staticURL: URL + let url: URL + let visibleInPicker: Bool + + init(shortcode: String, staticURL: URL, url: URL, visibleInPicker: Bool) { + self.shortcode = shortcode + self.staticURL = staticURL + self.url = url + self.visibleInPicker = visibleInPicker + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(shortcode, forKey: "shortcode") + aCoder.encode(staticURL, forKey: "staticURL") + aCoder.encode(url, forKey: "url") + aCoder.encode(visibleInPicker, forKey: "visibleInPicker") + } + + convenience required public init?(coder aDecoder: NSCoder) { + guard + let shortcode = aDecoder.decodeObject(forKey: "shortcode") as? String, + let staticURL = aDecoder.decodeObject(forKey: "staticURL") as? URL, + let url = aDecoder.decodeObject(forKey: "url") as? URL + else { + return nil + } + + self.init(shortcode: shortcode, staticURL: staticURL, url: url, visibleInPicker: aDecoder.decodeBool(forKey: "acct")) + } +} + +@objc public class PaginationMarker: NSObject, NSCoding { + let context: String + let item: PaginationItem + + init(context: String, item: PaginationItem) { + self.context = context + self.item = item + } + + public func encode(with aCoder: NSCoder) { + aCoder.encode(context, forKey: "context") + aCoder.encode(item, forKey: "item") + } + + convenience required public init?(coder aDecoder: NSCoder) { + guard + let context = aDecoder.decodeObject(forKey: "context") as? String, + let item = aDecoder.decodeObject(forKey: "item") as? PaginationItem + else { + return nil + } + + self.init(context: context, item: item) + } +} + public class MastodonDataManager { - static func setAccount(_ account: AccountMO, withRemoteAccount remoteAccount: Account) -> AccountMO { - account.id = remoteAccount.id - account.username = remoteAccount.username - account.acct = remoteAccount.acct - account.displayName = remoteAccount.displayName - account.note = remoteAccount.note - account.url = remoteAccount.url - account.avatarURL = URL(string: remoteAccount.avatar) - account.avatarStaticURL = URL(string: remoteAccount.avatarStatic) - account.headerURL = URL(string: remoteAccount.header) - account.headerStaticURL = URL(string: remoteAccount.headerStatic) - account.locked = remoteAccount.locked - account.createdAt = remoteAccount.createdAt - account.followersCount = Int32(remoteAccount.followersCount) - account.followingCount = Int32(remoteAccount.followingCount) - account.statusesCount = Int32(remoteAccount.statusesCount) + static var dateFormatter: DateFormatter { + let dateFormatter = DateFormatter() + + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" + dateFormatter.timeZone = TimeZone(abbreviation: "UTC") + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return dateFormatter + } + + static func setAccount(_ account: AccountMO, withData data: JSONObject) -> AccountMO { + let dateFormatter = self.dateFormatter + account.fetchedAt = Date() + account.id = data["id"] as? String + account.username = data["username"] as? String + account.acct = data["acct"] as? String + account.displayName = data["display_name"] as? String + account.note = data["note"] as? String + account.url = URL(string: data["url"] as! String) + account.avatarURL = URL(string: data["avatar"] as! String) + account.avatarStaticURL = URL(string: data["avatar_static"] as! String) + account.headerURL = URL(string: data["header"] as! String) + account.headerStaticURL = URL(string: data["header_static"] as! String) + account.locked = data["locked"] as? Bool ?? false + account.createdAt = dateFormatter.date(from: data["created_at"] as! String) + account.followersCount = data["followers_count"] as! Int32 + account.followingCount = data["following_count"] as! Int32 + account.statusesCount = data["statuses_count"] as! Int32 + + if let fields = data["fields"] as? [[String: String]] { + account.fields = fields.map { field in + return AccountField(name: field["name"]!, value: field["value"]!) + } + } return account } - static func upsertAccount(_ remoteAccount: Account) -> AccountMO? { + static func upsertAccount(_ data: JSONObject) -> AccountMO? { let request = NSFetchRequest(entityName: "Account") - request.predicate = NSPredicate(format: "id == %@", remoteAccount.id) + request.predicate = NSPredicate(format: "id == %@", data["id"] as! String) do { let results = try CoreDataManager.shared.context.fetch(request) if let account = results.first { - return setAccount(account, withRemoteAccount: remoteAccount) + return setAccount(account, withData: data) } else { - return setAccount(AccountMO(context: CoreDataManager.shared.context), withRemoteAccount: remoteAccount) + return setAccount(AccountMO(context: CoreDataManager.shared.context), withData: data) } } catch { print("\(error)") @@ -59,55 +195,35 @@ public class MastodonDataManager { } } - static func setAttachment(_ attachment: AttachmentMO, withRemoteAttachment remoteAttachment: Attachment) -> AttachmentMO { - attachment.id = remoteAttachment.id - attachment.type = remoteAttachment.type.rawValue - attachment.url = URL(string: remoteAttachment.url) - attachment.previewURL = URL(string: remoteAttachment.previewURL) - attachment.remoteURL = remoteAttachment.remoteURL - attachment.textURL = remoteAttachment.textURL + static func setAttachment(_ attachment: AttachmentMO, withData data: JSONObject) -> AttachmentMO { + attachment.id = data["id"] as? String + attachment.type = data["type"] as? String + attachment.url = URL(string: data["url"] as! String) + attachment.previewURL = URL(string: data["preview_url"] as! String) + + if let remoteURL = data["remote_url"] as? String { + attachment.remoteURL = URL(string: remoteURL) + } + + if let textURL = data["text_url"] as? String { + attachment.textURL = URL(string: textURL) + } + attachment.fetchedAt = Date() return attachment } - static func upsertAttachment(_ remoteAttachment: Attachment) -> AttachmentMO? { + static func upsertAttachment(_ data: JSONObject) -> AttachmentMO? { let request = NSFetchRequest(entityName: "Attachment") - request.predicate = NSPredicate(format: "id == %@", remoteAttachment.id) + request.predicate = NSPredicate(format: "id == %@", data["id"] as! String) do { let results = try CoreDataManager.shared.context.fetch(request) if let attachment = results.first { - return setAttachment(attachment, withRemoteAttachment: remoteAttachment) - } else { - return setAttachment(AttachmentMO(context: CoreDataManager.shared.context), withRemoteAttachment: remoteAttachment) - } - } catch { - print("\(error)") - return nil - } - } - - static func setMention(_ mention: MentionMO, withRemoteMention remoteMention: Mention) -> MentionMO { - mention.id = remoteMention.id - mention.username = remoteMention.username - mention.acct = remoteMention.acct - mention.url = URL(string: remoteMention.url) - mention.fetchedAt = Date() - - return mention - } - - static func upsertMention(_ remoteMention: Mention) -> MentionMO? { - let request = NSFetchRequest(entityName: "Mention") - request.predicate = NSPredicate(format: "id == %@", remoteMention.id) - - do { - let results = try CoreDataManager.shared.context.fetch(request) - if let mention = results.first { - return setMention(mention, withRemoteMention: remoteMention) + return setAttachment(attachment, withData: data) } else { - return setMention(MentionMO(context: CoreDataManager.shared.context), withRemoteMention: remoteMention) + return setAttachment(AttachmentMO(context: CoreDataManager.shared.context), withData: data) } } catch { print("\(error)") @@ -115,23 +231,23 @@ public class MastodonDataManager { } } - static func setTag(_ tag: TagMO, withRemoteTag remoteTag: Tag) -> TagMO { - tag.name = remoteTag.name - tag.url = URL(string: remoteTag.url) + static func setTag(_ tag: TagMO, withData data: JSONObject) -> TagMO { + tag.name = data["name"] as? String + tag.url = URL(string: data["url"] as! String) return tag } - static func upsertTag(_ remoteTag: Tag) -> TagMO? { + static func upsertTag(_ data: JSONObject) -> TagMO? { let request = NSFetchRequest(entityName: "Tag") - request.predicate = NSPredicate(format: "name == %@", remoteTag.name) + request.predicate = NSPredicate(format: "name == %@", data["name"] as! String) do { let results = try CoreDataManager.shared.context.fetch(request) if let tag = results.first { - return setTag(tag, withRemoteTag: remoteTag) + return setTag(tag, withData: data) } else { - return setTag(TagMO(context: CoreDataManager.shared.context), withRemoteTag: remoteTag) + return setTag(TagMO(context: CoreDataManager.shared.context), withData: data) } } catch { print("\(error)") @@ -139,26 +255,26 @@ public class MastodonDataManager { } } - static func setApp(_ app: AppMO, withRemoteApp remoteApp: Application) -> AppMO { - app.name = remoteApp.name + static func setApp(_ app: AppMO, withData data: JSONObject) -> AppMO { + app.name = data["name"] as? String - if let website = remoteApp.website { + if let website = data["website"] as? String { app.website = URL(string: website) } return app } - static func upsertApp(_ remoteApp: Application) -> AppMO? { + static func upsertApp(_ data: JSONObject) -> AppMO? { let request = NSFetchRequest(entityName: "App") - request.predicate = NSPredicate(format: "name == %@", remoteApp.name) + request.predicate = NSPredicate(format: "name == %@", data["name"] as! String) do { let results = try CoreDataManager.shared.context.fetch(request) if let app = results.first { - return setApp(app, withRemoteApp: remoteApp) + return setApp(app, withData: data) } else { - return setApp(AppMO(context: CoreDataManager.shared.context), withRemoteApp: remoteApp) + return setApp(AppMO(context: CoreDataManager.shared.context), withData: data) } } catch { print("\(error)") @@ -166,106 +282,98 @@ public class MastodonDataManager { } } - static func setEmoji(_ emoji: EmojiMO, withRemoteEmoji remoteEmoji: Emoji) -> EmojiMO { - emoji.shortcode = remoteEmoji.shortcode - emoji.staticURL = remoteEmoji.staticURL - emoji.url = remoteEmoji.url - emoji.visibleInPicker = false - emoji.fetchedAt = Date() - - return emoji - } - - static func upsertEmoji(_ remoteEmoji: Emoji) -> EmojiMO? { - let request = NSFetchRequest(entityName: "Emoji") - request.predicate = NSPredicate(format: "url == %@", remoteEmoji.url.absoluteString) + static func setStatus(_ status: StatusMO, withData data: JSONObject) -> StatusMO { + let dateFormatter = self.dateFormatter - do { - let results = try CoreDataManager.shared.context.fetch(request) - if let emoji = results.first { - return setEmoji(emoji, withRemoteEmoji: remoteEmoji) - } else { - return setEmoji(EmojiMO(context: CoreDataManager.shared.context), withRemoteEmoji: remoteEmoji) - } - } catch { - print("\(error)") - return nil - } - } - - static func setStatus(_ status: StatusMO, withRemoteStatus remoteStatus: Status) -> StatusMO { - status.id = remoteStatus.id - status.uri = URL(string: remoteStatus.uri) - status.url = remoteStatus.url - status.account = MastodonDataManager.upsertAccount(remoteStatus.account) - status.inReplyToID = remoteStatus.inReplyToID - status.inReplyToAccountID = remoteStatus.inReplyToAccountID - status.content = remoteStatus.content - status.createdAt = remoteStatus.createdAt - status.reblogsCount = Int32(remoteStatus.reblogsCount) - status.favouritesCount = Int32(remoteStatus.favouritesCount) - status.reblogged = remoteStatus.reblogged ?? false - status.favourited = remoteStatus.favourited ?? false - status.sensitive = remoteStatus.sensitive ?? false - status.pinned = remoteStatus.pinned ?? false - status.spoilerText = remoteStatus.spoilerText - status.visibility = remoteStatus.visibility.rawValue + status.id = data["id"] as? String + status.uri = URL(string: data["uri"] as! String) + status.account = MastodonDataManager.upsertAccount(data["account"] as! JSONObject) + status.inReplyToID = data["in_reply_to_id"] as? String + status.inReplyToAccountID = data["in_reply_to_account_id"] as? String + status.content = data["content"] as? String + status.createdAt = dateFormatter.date(from: data["created_at"] as! String) + status.reblogsCount = data["reblogs_count"] as! Int32 + status.favoritesCount = data["favourites_count"] as! Int32 + status.reblogged = data["reblogged"] as? Bool ?? false + status.favorited = data["favourited"] as? Bool ?? false + status.sensitive = data["sensitive"] as? Bool ?? false + status.pinned = data["pinned"] as? Bool ?? false + status.spoilerText = data["spoiler_text"] as? String + status.visibility = data["visibility"] as? String status.fetchedAt = Date() - if let app = remoteStatus.application { + if let url = data["url"] as? String { + status.url = URL(string: url) + } + + if let app = data["application"] as? JSONObject { status.app = MastodonDataManager.upsertApp(app) } - remoteStatus.mediaAttachments.forEach { attachment in - if let attachment = MastodonDataManager.upsertAttachment(attachment) { - status.mutableOrderedSetValue(forKey: "attachments").add(attachment) + if let attachments = data["media_attachments"] as? [JSONObject] { + attachments.forEach { attachment in + if let attachment = MastodonDataManager.upsertAttachment(attachment) { + status.addToAttachments(attachment) + } } } - remoteStatus.mentions.forEach { mention in - if let mention = MastodonDataManager.upsertMention(mention) { - status.mutableSetValue(forKey: "mentions").add(mention) + if let mentions = data["mentions"] as? [JSONObject] { + status.mentions = mentions.map { mention in + return Mention( + id: mention["id"] as! String, + url: URL(string: mention["url"] as! String)!, + username: mention["username"] as! String, + acct: mention["acct"] as! String + ) } } - remoteStatus.tags.forEach { tag in - if let tag = MastodonDataManager.upsertTag(tag) { - status.mutableSetValue(forKey: "tags").add(tag) + if let tags = data["tags"] as? [JSONObject] { + tags.forEach { tag in + if let tag = MastodonDataManager.upsertTag(tag) { + status.addToTags(tag) + } } } - remoteStatus.emojis.forEach { emoji in - if let emoji = MastodonDataManager.upsertEmoji(emoji) { - status.mutableSetValue(forKey: "emojis").add(emoji) + if let emojis = data["emojis"] as? [JSONObject] { + status.emojis = emojis.map { emoji in + return Emoji( + shortcode: emoji["shortcode"] as! String, + staticURL: URL(string: emoji["static_url"] as! String)!, + url: URL(string: emoji["url"] as! String)!, + visibleInPicker: emoji["visible_in_picker"] as? Bool ?? false + ) } } - if let reblog = remoteStatus.reblog { - let savedReblogResult = upsertStatus(reblog) - status.reblog = savedReblogResult?.model + if let reblog = data["reblog"] as? JSONObject { + let upsertResult = upsertStatus(reblog) + status.reblog = upsertResult?.object } return status } - static func upsertStatus(_ remoteStatus: Status) -> UpsertResult? { + static func upsertStatus(_ data: JSONObject) -> UpsertResult? { let request = NSFetchRequest(entityName: "Status") - request.predicate = NSPredicate(format: "id == %@", remoteStatus.id) + request.predicate = NSPredicate(format: "id == %@", data["id"] as! String) do { let results = try CoreDataManager.shared.context.fetch(request) if let status = results.first { return UpsertResult( - model: setStatus(status, withRemoteStatus: remoteStatus), + object: setStatus(status, withData: data), new: false ) } else { let status = StatusMO(context: CoreDataManager.shared.context) - status.hidden = !remoteStatus.spoilerText.isEmpty + status.hidden = !(data["spoiler_text"] as! String).isEmpty return UpsertResult( - model: setStatus(status, withRemoteStatus: remoteStatus), + object: setStatus(status, withData: data), new: true ) } @@ -275,18 +383,18 @@ public class MastodonDataManager { } } - static func saveClient(id: String, clientID: String, clientSecret: String, url: String) -> ClientMO { + static func saveClient(id: String, clientID: String, clientSecret: String, host: String) -> ClientMO { let client = ClientMO(context: CoreDataManager.shared.context) client.id = id client.clientID = clientID client.clientSecret = clientSecret - client.url = url + client.host = host CoreDataManager.shared.saveContext() return client } - static func accountByID(_ id: String) -> AccountMO? { + static func account(id: String) -> AccountMO? { let request = NSFetchRequest(entityName: "Account") request.predicate = NSPredicate(format: "id == %@", id) @@ -302,29 +410,4 @@ public class MastodonDataManager { return nil } } - - static func remoteAccountByID(id: String, completion: @escaping (AccountMO?, Error?) -> Void ) { - if let account = accountByID(id) { - completion(account, nil) - return - } - - guard let client = AuthenticationManager.shared.mkClient else { - completion(nil, NSError()) - return - } - - let request = Accounts.account(id: id) - - client.run(request) { result in - switch result { - case .success(let account, _): - completion(upsertAccount(account), nil) - return - case .failure(let error): - completion(nil, error) - return - } - } - } } diff --git a/elpha-ios/StatusTableViewController.swift b/elpha-ios/StatusTableViewController.swift index e3a475a..fd4d8c4 100644 --- a/elpha-ios/StatusTableViewController.swift +++ b/elpha-ios/StatusTableViewController.swift @@ -8,7 +8,6 @@ import AlamofireImage import CoreData -import MastodonKit import UIKit enum StatusType { @@ -35,7 +34,7 @@ class StatusTableViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - // navigationItem.title = "Toot" + navigationItem.title = "Detail" } override func viewDidAppear(_ animated: Bool) { @@ -49,60 +48,54 @@ class StatusTableViewController: UITableViewController { } func fetchStatuses(completion: @escaping (Error?) -> Void) { - guard let client = AuthenticationManager.shared.mkClient else { - completion(nil) - return - } - if let status = status { loading = true - let statusRequest = Statuses.status(id: status.id!) - let contextRequest = Statuses.context(id: status.id!) - - client.run(statusRequest) { result in - switch result { - case .success(let remoteStatus, _): - DispatchQueue.main.async { - _ = MastodonDataManager.upsertStatus(remoteStatus) + MastodonAPI.status(id: status.id!) { data, error in + guard let data = data, error == nil else { + completion(error) + return + } + + DispatchQueue.main.async { + _ = MastodonDataManager.upsertStatus(data) + } + + MastodonAPI.context(id: status.id!) { data, error in + guard let data = data, error == nil else { + completion(error) + return + } + + DispatchQueue.main.async { + if let ancestors = data["ancestors"] as? [JSONObject] { + ancestors.forEach { ancestor in + let ancestor = MastodonDataManager.upsertStatus(ancestor) + status.addToAncestors(ancestor!.object) + } } - - client.run(contextRequest) { result in - switch result { - case .success(let context, _): - DispatchQueue.main.async { - context.ancestors.forEach { remoteAncestor in - let ancestor = MastodonDataManager.upsertStatus(remoteAncestor) - status.addToAncestors(ancestor!.model) - } - - context.ancestors.forEach { remoteDescendant in - let descendant = MastodonDataManager.upsertStatus(remoteDescendant) - status.addToDescendants(descendant!.model) - } - - CoreDataManager.shared.saveContext() - - self.loading = false - self.loadStatuses() - self.tableView.reloadData() - - completion(nil) - } - case .failure(let error): - completion(error) + + if let descendant = data["descendants"] as? [JSONObject] { + descendant.forEach { descendant in + let descendant = MastodonDataManager.upsertStatus(descendant) + status.addToDescendants(descendant!.object) } } - case .failure(let error): - completion(error) + + CoreDataManager.shared.saveContext() + + self.loading = false + self.loadStatuses() + self.tableView.reloadData() + + completion(nil) } + } } } } func loadStatuses() { - print("Running loadStatuses()") - guard let status = status else { return } @@ -117,10 +110,10 @@ class StatusTableViewController: UITableViewController { let statuses = try CoreDataManager.shared.context.fetch(request) self.statuses = Dictionary(grouping: statuses) { s -> StatusType in switch s.createdAt!.compare(status.createdAt!) { - case .orderedSame: - return .status case .orderedAscending: return .ancestor + case .orderedSame: + return .status case .orderedDescending: return .descendant } diff --git a/elpha-ios/StatusView.swift b/elpha-ios/StatusView.swift index 16e23df..efb4849 100644 --- a/elpha-ios/StatusView.swift +++ b/elpha-ios/StatusView.swift @@ -53,7 +53,7 @@ class StatusView: UIView { if let delegate = delegate, let status = status, let replyAccountID = status.inReplyToAccountID, - let replyAccount = MastodonDataManager.accountByID(replyAccountID) { + let replyAccount = MastodonDataManager.account(id: replyAccountID) { delegate.accountTapped(account: replyAccount) } } @@ -166,7 +166,7 @@ class StatusView: UIView { timestampLabel.text = status.createdAt!.timeAgo() repliesLabel.text = "0" boostsLabel.text = NumberFormatter.localizedString(from: NSNumber(value: status.reblogsCount), number: .decimal) - favoritesLabel.text = NumberFormatter.localizedString(from: NSNumber(value: status.favouritesCount), number: .decimal) + favoritesLabel.text = NumberFormatter.localizedString(from: NSNumber(value: status.favoritesCount), number: .decimal) if status.reblogged { boostsImageView.image = UIImage(named: "Boost Bold") @@ -174,7 +174,7 @@ class StatusView: UIView { boostsImageView.image = UIImage(named: "Boost Regular") } - if status.favourited { + if status.favorited { favoritesImageView.image = UIImage(named: "Star Filled") } else { favoritesImageView.image = UIImage(named: "Star Regular") @@ -202,7 +202,7 @@ class StatusView: UIView { } if let replyAccountID = status.inReplyToAccountID { - if let replyAccount = MastodonDataManager.accountByID(replyAccountID) { + if let replyAccount = MastodonDataManager.account(id: replyAccountID) { replyView.isHidden = false replyAvatarImageView.af_setImage(withURL: replyAccount.avatarURL!, filter: avatarFilter) replyDisplayNameLabel.text = replyAccount.displayName diff --git a/elpha-ios/TimelineTableViewController.swift b/elpha-ios/TimelineTableViewController.swift index d5f0a4d..ee08ee2 100644 --- a/elpha-ios/TimelineTableViewController.swift +++ b/elpha-ios/TimelineTableViewController.swift @@ -8,7 +8,6 @@ import AlamofireImage import CoreData -import MastodonKit import UIKit class TimelineTableViewController: UITableViewController, TimelinesTableViewControllerDelegate { @@ -27,36 +26,47 @@ class TimelineTableViewController: UITableViewController, TimelinesTableViewCont } } + var currentPaginationContext: String { + guard let timeline = AuthenticationManager.session?.timeline, let categoryString = timeline.category else { + return "" + } + + switch TimelineCategory(rawValue: categoryString)! { + case .home: + return "timeline:home" + case .local: + return "timeline:local" + case .federated: + return "timeline:federated" + case .tag: + return "timeline:tag:\(timeline.subcategory!)" + case .favorites: + return "timeline:favorites" + } + } + override func viewDidLoad() { super.viewDidLoad() initializeFetchedResultsController() let moreButtonItem = UIBarButtonItem(image: UIImage(named: "More"), style: .plain, target: self, action: #selector(more)) - let cleanButtonItem = UIBarButtonItem(image: UIImage(named: "Refresh CCW"), style: .plain, target: self, action: #selector(clean)) let composeButtonItem = UIBarButtonItem(image: UIImage(named: "Compose"), style: .plain, target: self, action: #selector(compose)) navigationItem.leftBarButtonItems = [moreButtonItem] - navigationItem.rightBarButtonItems = [composeButtonItem, cleanButtonItem] + navigationItem.rightBarButtonItems = [composeButtonItem] - if let timeline = AuthenticationManager.shared.session?.timeline { + if let timeline = AuthenticationManager.session?.timeline { navigationItem.title = timeline.name } else { navigationItem.title = "Home" } - refreshControl?.addTarget(self, action: #selector(self.fetchTimelineWithDefaultRange), for: .valueChanged) + refreshControl?.addTarget(self, action: #selector(self.fetch), for: .valueChanged) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - fetchTimelineWithDefaultRange() - } - - @objc func clean() { - if let timeline = AuthenticationManager.shared.session?.timeline { - timeline.mutableSetValue(forKey: "statuses").removeAllObjects() - CoreDataManager.shared.saveContext() - } + // fetchTimeline() } @objc func more() { @@ -79,7 +89,7 @@ class TimelineTableViewController: UITableViewController, TimelinesTableViewCont func didSelect(timeline: TimelineMO) { navigationItem.title = timeline.name - AuthenticationManager.shared.session?.timeline = timeline + AuthenticationManager.session?.timeline = timeline CoreDataManager.shared.saveContext() initializeFetchedResultsController() @@ -87,62 +97,43 @@ class TimelineTableViewController: UITableViewController, TimelinesTableViewCont } func createDefaultTimelines(account: AccountMO) { - let names = [ - "Home", - "Local", - "Federated", - "Favorites", - ] - - for (index, name) in names.enumerated() { - let timeline = TimelineMO(context: CoreDataManager.shared.context) - timeline.name = name - timeline.account = account - timeline.order = Int16(index) - } + let homeTimeline = TimelineMO(context: CoreDataManager.shared.context) + homeTimeline.name = "Home" + homeTimeline.category = TimelineCategory.home.rawValue + homeTimeline.account = account + homeTimeline.order = 1 + + let localTimeline = TimelineMO(context: CoreDataManager.shared.context) + localTimeline.name = "Local" + localTimeline.category = TimelineCategory.local.rawValue + localTimeline.account = account + localTimeline.order = 2 + + let federatedTimeline = TimelineMO(context: CoreDataManager.shared.context) + federatedTimeline.name = "Home" + federatedTimeline.category = TimelineCategory.federated.rawValue + federatedTimeline.account = account + federatedTimeline.order = 3 + + let favoritesTimeline = TimelineMO(context: CoreDataManager.shared.context) + favoritesTimeline.name = "Home" + favoritesTimeline.category = TimelineCategory.favorites.rawValue + favoritesTimeline.account = account + favoritesTimeline.order = 4 CoreDataManager.shared.saveContext() } - func fetchStatuses(request: Request<[Status]>, forTimeline timeline: TimelineMO, completion: @escaping ([UpsertResult], Error?) -> Void) { - guard let client = AuthenticationManager.shared.mkClient else { - completion([], nil) - return - } - - print("Request: \(request)") - - client.run(request) { result in - switch result { - case .success(let remoteStatuses, _): - DispatchQueue.main.async { - let statuses = remoteStatuses.compactMap { status in - return MastodonDataManager.upsertStatus(status) - } - - timeline.addToStatuses(NSSet(array: statuses.map { $0.model })) - - CoreDataManager.shared.saveContext() - - self.loading = false - completion(statuses, nil) - } - case .failure(let error): - completion([], error) - } - } - } - - @objc func fetchTimelineWithDefaultRange() { - fetchTimeline(withRange: .limit(fetchLimit)) { error in + @objc func fetch() { + fetchTimeline { error in if error != nil { print("\(String(describing: error))") } } } - func fetchTimeline(withRange requestRange: RequestRange, completion: @escaping (Error?) -> Void) { - guard let session = AuthenticationManager.shared.session, let account = session.account else { + func fetchTimeline(withPagination pagination: PaginationItem? = nil, completion: @escaping (Error?) -> Void) { + guard let session = AuthenticationManager.session, let account = session.account else { completion(nil) return } @@ -153,7 +144,7 @@ class TimelineTableViewController: UITableViewController, TimelinesTableViewCont } let request = NSFetchRequest(entityName: "Timeline") - request.predicate = NSPredicate(format: "name == %@", "Home") + request.predicate = NSPredicate(format: "category == %@", TimelineCategory.home.rawValue) do { let results = try CoreDataManager.shared.context.fetch(request) @@ -169,44 +160,91 @@ class TimelineTableViewController: UITableViewController, TimelinesTableViewCont return } - loading = true - - var request: Request<[Status]> - - switch timeline.name { - case "Home": - request = Timelines.home(range: requestRange) - case "Local": - request = Timelines.public(local: true, range: requestRange) - case "Federated": - request = Timelines.public(local: false, range: requestRange) - case "Favorites": - request = Favourites.all(range: requestRange) - case let tag: - request = Timelines.tag(tag!, range: requestRange) - } - - fetchStatuses(request: request, forTimeline: timeline) { statuses, error in - guard error == nil else { + func requestCompletion(data: [JSONObject]?, pagination: [PaginationItem]?, error: Error?) { + guard let data = data, error == nil else { completion(error) return } - let newStatuses = statuses.filter { $0.new } - if newStatuses.count > 0 { - DispatchQueue.main.async { - // NewStatusesView stuff + DispatchQueue.main.async { + for (index, status) in data.enumerated() { + if let upsertResult = MastodonDataManager.upsertStatus(status) { + let status = upsertResult.object + + if let pagination = pagination { + var markers: [PaginationMarker] = status.markers ?? [] + + if index == 0 { + pagination.forEach { item in + if item.direction == .prev { + markers.append(PaginationMarker(context: self.currentPaginationContext, item: item)) + } + } + } + + if index == data.count - 1 { + pagination.forEach { item in + if item.direction == .next { + markers.append(PaginationMarker(context: self.currentPaginationContext, item: item)) + } + } + } + } + + timeline.addToStatuses(status) + } } + + CoreDataManager.shared.saveContext() + + self.loading = false + completion(nil) } - - completion(nil) + } + + loading = true + + switch TimelineCategory(rawValue: timeline.category!)! { + case .home: + MastodonAPI.homeTimeline( + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) + case .local: + MastodonAPI.publicTimeline( + local: true, + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) + case .federated: + MastodonAPI.publicTimeline( + local: false, + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) + case .tag: + MastodonAPI.tagTimeline( + local: false, + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) + case .favorites: + MastodonAPI.favorites( + limit: fetchLimit, + pagination: pagination, + completion: requestCompletion + ) } } } extension TimelineTableViewController: NSFetchedResultsControllerDelegate { func initializeFetchedResultsController() { - guard let timeline = AuthenticationManager.shared.session?.timeline else { + guard let timeline = AuthenticationManager.session?.timeline else { return } @@ -263,17 +301,10 @@ extension TimelineTableViewController { cell.statusView.update(withStatus: status) cell.statusView.topDividerView.isHidden = indexPath.row == 0 - if !loading { - let statusAge = Calendar.current.dateComponents([.minute], from: status.fetchedAt!, to: Date()) - let stale = statusAge.minute! > 30 - let first = indexPath.row == 0 - let last = indexPath.row == (fetchedResultsController?.fetchedObjects?.count ?? 0) - 1 - - if (!first && stale) || last { - fetchTimeline(withRange: .max(id: status.id!, limit: fetchLimit)) { error in - if error != nil { - print("\(String(describing: error))") - } + if let markers = status.markers { + markers.forEach { marker in + if marker.context == self.currentPaginationContext { + print("Found marker: \(marker)") } } } diff --git a/elpha-ios/TimelinesTableViewController.swift b/elpha-ios/TimelinesTableViewController.swift index 2be469b..43d524f 100644 --- a/elpha-ios/TimelinesTableViewController.swift +++ b/elpha-ios/TimelinesTableViewController.swift @@ -65,7 +65,7 @@ class TimelinesTableViewController: UITableViewController { fatalError("CoreData error") } - let selectedTimeline = AuthenticationManager.shared.session?.timeline + let selectedTimeline = AuthenticationManager.session?.timeline cell.timelineLabel.text = timeline.name