Browse Source

Development

master
Dwayne Harris 6 years ago
parent
commit
dde2375789
  1. 110
      elpha-ios.xcodeproj/project.pbxproj
  2. 160
      elpha-ios/AccountTableViewController.swift
  3. 83
      elpha-ios/AuthenticateViewController.swift
  4. 27
      elpha-ios/AuthenticationManager.swift
  5. 1
      elpha-ios/Base.lproj/Main.storyboard
  6. 2
      elpha-ios/Configuration.swift
  7. 56
      elpha-ios/Elpha.xcdatamodeld/Elpha.xcdatamodel/contents
  8. 9
      elpha-ios/InstanceRequest.swift
  9. 47
      elpha-ios/InstancesDataManager.swift
  10. 2
      elpha-ios/MainTabBarController.swift
  11. 319
      elpha-ios/MastodonAPI.swift
  12. 421
      elpha-ios/MastodonDataManager.swift
  13. 85
      elpha-ios/StatusTableViewController.swift
  14. 8
      elpha-ios/StatusView.swift
  15. 227
      elpha-ios/TimelineTableViewController.swift
  16. 2
      elpha-ios/TimelinesTableViewController.swift

110
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 = "<group>"; };
156FF04E2175CDBC0074D9CA /* MainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStatusTableViewCell.swift; sourceTree = "<group>"; };
156FF050217683270074D9CA /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
156FF06F21779C570074D9CA /* MastodonAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAPI.swift; sourceTree = "<group>"; };
156FF07121779C650074D9CA /* InstanceRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceRequest.swift; sourceTree = "<group>"; };
157405A72150588A00EEAAEB /* InstanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceViewController.swift; sourceTree = "<group>"; };
157405AF2151A5DA00EEAAEB /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
157405B32151A93E00EEAAEB /* InstancesDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesDataManager.swift; sourceTree = "<group>"; };
@ -317,7 +283,6 @@
15960E7D21329FED00C38CE9 /* AuthenticateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticateViewController.swift; sourceTree = "<group>"; };
15960E812136668500C38CE9 /* TimelinesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesNavigationController.swift; sourceTree = "<group>"; };
15960E83213774FC00C38CE9 /* InstancesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesTableViewController.swift; sourceTree = "<group>"; };
15A79AE7215B3CC5007A326E /* MastodonKit.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = MastodonKit.xcodeproj; path = Frameworks/MastodonKit/MastodonKit.xcodeproj; sourceTree = "<group>"; };
15A79B08215B438C007A326E /* OAuthSwift.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = OAuthSwift.xcodeproj; path = Frameworks/OAuthSwift/OAuthSwift.xcodeproj; sourceTree = "<group>"; };
15A79B42215EB959007A326E /* CoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManager.swift; sourceTree = "<group>"; };
15BB72A82171A6BE002F1FA4 /* TimelinesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesTableViewController.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
156FF05621779C140074D9CA /* API */ = {
isa = PBXGroup;
children = (
156FF07121779C650074D9CA /* InstanceRequest.swift */,
156FF06F21779C570074D9CA /* MastodonAPI.swift */,
);
name = API;
sourceTree = "<group>";
};
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 = "<group>";
};
15A79AE8215B3CC5007A326E /* Products */ = {
isa = PBXGroup;
children = (
15A79B00215B3CC5007A326E /* MastodonKit.framework */,
15A79B02215B3CC5007A326E /* MastodonKit.framework */,
15A79B04215B3CC5007A326E /* MastodonKit.framework */,
15A79B06215B3CC5007A326E /* MastodonKitTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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";

160
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<StatusMO>? = 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<StatusMO>], 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)")
}
}
}

83
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<ClientMO>(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 {

27
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<SessionMO>(entityName: "Session")
@ -25,7 +22,7 @@ class AuthenticationManager {
}
}
var session: SessionMO? {
static var session: SessionMO? {
get {
let request = NSFetchRequest<SessionMO>(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
}

1
elpha-ios/Base.lproj/Main.storyboard

@ -976,6 +976,7 @@
</connections>
</tableView>
<refreshControl key="refreshControl" opaque="NO" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" enabled="NO" contentHorizontalAlignment="center" contentVerticalAlignment="center" id="1F2-ct-Zy0">
<rect key="frame" x="0.0" y="0.0" width="1000" height="1000"/>
<autoresizingMask key="autoresizingMask"/>
<attributedString key="attributedTitle">
<fragment content="Loading...">

2
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
}

56
elpha-ios/Elpha.xcdatamodeld/Elpha.xcdatamodel/contents

@ -8,6 +8,7 @@
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="displayName" attributeType="String" syncable="YES"/>
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="fields" optional="YES" attributeType="Transformable" customClassName="[AccountField]" syncable="YES"/>
<attribute name="followersCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="followingCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="headerStaticURL" optional="YES" attributeType="URI" syncable="YES"/>
@ -17,9 +18,9 @@
<attribute name="moved" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="note" attributeType="String" syncable="YES"/>
<attribute name="statusesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="url" attributeType="String" syncable="YES"/>
<attribute name="url" attributeType="URI" syncable="YES"/>
<attribute name="username" attributeType="String" syncable="YES"/>
<relationship name="sessions" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Session" inverseName="account" inverseEntity="Session" syncable="YES"/>
<relationship name="sessions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Session" inverseName="account" inverseEntity="Session" syncable="YES"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="account" inverseEntity="Status" syncable="YES"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Timeline" inverseName="account" inverseEntity="Timeline" syncable="YES"/>
</entity>
@ -33,8 +34,8 @@
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="id" attributeType="String" syncable="YES"/>
<attribute name="previewURL" attributeType="URI" syncable="YES"/>
<attribute name="remoteURL" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="textURL" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="remoteURL" optional="YES" attributeType="URI" syncable="YES"/>
<attribute name="textURL" optional="YES" attributeType="URI" syncable="YES"/>
<attribute name="type" attributeType="String" syncable="YES"/>
<attribute name="url" attributeType="URI" syncable="YES"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="attachments" inverseEntity="Status" syncable="YES"/>
@ -42,19 +43,10 @@
<entity name="Client" representedClassName="ClientMO" syncable="YES" codeGenerationType="class">
<attribute name="clientID" attributeType="String" syncable="YES"/>
<attribute name="clientSecret" attributeType="String" syncable="YES"/>
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="host" attributeType="String" syncable="YES"/>
<attribute name="id" attributeType="String" syncable="YES"/>
<attribute name="url" attributeType="String" syncable="YES"/>
<relationship name="sessions" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Session" inverseName="client" inverseEntity="Session" syncable="YES"/>
</entity>
<entity name="Emoji" representedClassName="EmojiMO" syncable="YES" codeGenerationType="class">
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="shortcode" attributeType="String" syncable="YES"/>
<attribute name="staticURL" attributeType="URI" syncable="YES"/>
<attribute name="url" attributeType="URI" syncable="YES"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="emojis" inverseEntity="Status" syncable="YES"/>
</entity>
<entity name="ISCategory" representedClassName="ISCategoryMO" syncable="YES" codeGenerationType="class">
<attribute name="id" attributeType="String" syncable="YES"/>
<relationship name="instances" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="ISInstance" inverseName="categories" inverseEntity="ISInstance" syncable="YES"/>
@ -70,6 +62,7 @@
<attribute name="connections" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="dead" optional="YES" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="email" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="fullDescription" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="https" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="id" optional="YES" attributeType="String" syncable="YES"/>
@ -101,14 +94,6 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Mention" representedClassName="MentionMO" syncable="YES" codeGenerationType="class">
<attribute name="acct" attributeType="String" syncable="YES"/>
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="id" attributeType="String" syncable="YES"/>
<attribute name="url" attributeType="URI" syncable="YES"/>
<attribute name="username" attributeType="String" syncable="YES"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="mentions" inverseEntity="Status" syncable="YES"/>
</entity>
<entity name="Session" representedClassName="SessionMO" syncable="YES" codeGenerationType="class">
<attribute name="color" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
@ -121,13 +106,16 @@
<entity name="Status" representedClassName="StatusMO" syncable="YES" codeGenerationType="class">
<attribute name="content" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="favourited" optional="YES" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="favouritesCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="emojis" optional="YES" attributeType="Transformable" customClassName="[Emoji]" syncable="YES"/>
<attribute name="favorited" optional="YES" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="favoritesCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="fetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="hidden" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="id" attributeType="String" syncable="YES"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="inReplyToID" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="markers" optional="YES" attributeType="Transformable" customClassName="[PaginationMarker]" syncable="YES"/>
<attribute name="mentions" optional="YES" attributeType="Transformable" customClassName="[Mention]" syncable="YES"/>
<attribute name="pinned" optional="YES" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="reblogged" optional="YES" attributeType="Boolean" usesScalarValueType="YES" syncable="YES"/>
<attribute name="reblogsCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
@ -141,8 +129,6 @@
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="App" inverseName="statuses" inverseEntity="App" syncable="YES"/>
<relationship name="attachments" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Attachment" inverseName="status" inverseEntity="Attachment" syncable="YES"/>
<relationship name="descendants" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="ancestors" inverseEntity="Status" syncable="YES"/>
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="statuses" inverseEntity="Emoji" syncable="YES"/>
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Mention" inverseName="status" inverseEntity="Mention" syncable="YES"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status" syncable="YES"/>
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status" syncable="YES"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag" syncable="YES"/>
@ -154,25 +140,25 @@
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="tags" inverseEntity="Status" syncable="YES"/>
</entity>
<entity name="Timeline" representedClassName="TimelineMO" syncable="YES" codeGenerationType="class">
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="order" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="category" attributeType="String" syncable="YES"/>
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="subcategory" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="timelines" inverseEntity="Account" syncable="YES"/>
<relationship name="session" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Session" inverseName="timeline" inverseEntity="Session" syncable="YES"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="timelines" inverseEntity="Status" syncable="YES"/>
</entity>
<elements>
<element name="Account" positionX="-669.49609375" positionY="52.046875" width="128" height="360"/>
<element name="Account" positionX="-669.49609375" positionY="52.046875" width="128" height="375"/>
<element name="App" positionX="-423" positionY="252" width="128" height="105"/>
<element name="Attachment" positionX="-450" positionY="225" width="128" height="165"/>
<element name="Client" positionX="-244.4140625" positionY="307.203125" width="128" height="135"/>
<element name="Emoji" positionX="-468" positionY="207" width="128" height="135"/>
<element name="Client" positionX="-244.4140625" positionY="307.203125" width="128" height="120"/>
<element name="ISCategory" positionX="196.8984375" positionY="498.03515625" width="128" height="75"/>
<element name="ISInstance" positionX="-18" positionY="153" width="128" height="435"/>
<element name="ISInstance" positionX="-18" positionY="153" width="128" height="450"/>
<element name="ISLanguage" positionX="-286.21875" positionY="512.6171875" width="128" height="75"/>
<element name="Mention" positionX="-441" positionY="234" width="128" height="135"/>
<element name="Session" positionX="-445.046875" positionY="277.31640625" width="128" height="150"/>
<element name="Status" positionX="-459" positionY="216" width="128" height="465"/>
<element name="Status" positionX="-459" positionY="216" width="128" height="480"/>
<element name="Tag" positionX="-432" positionY="243" width="128" height="90"/>
<element name="Timeline" positionX="-468" positionY="207" width="128" height="120"/>
<element name="Timeline" positionX="-468" positionY="207" width="128" height="150"/>
</elements>
</model>

9
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

47
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)")

2
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
}

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

421
elpha-ios/MastodonDataManager.swift

@ -8,50 +8,186 @@
import CoreData
import Foundation
import MastodonKit
class UpsertResult<T> {
var model: T
class UpsertResult<T: NSManagedObject> {
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<AccountMO>(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<AttachmentMO>(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<MentionMO>(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<TagMO>(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<AppMO>(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<EmojiMO>(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<StatusMO>? {
static func upsertStatus(_ data: JSONObject) -> UpsertResult<StatusMO>? {
let request = NSFetchRequest<StatusMO>(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<AccountMO>(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
}
}
}
}

85
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
}

8
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

227
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<StatusMO>], 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<TimelineMO>(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)")
}
}
}

2
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

Loading…
Cancel
Save