From 1a4517c43a1c57337b540e8840009048f8f06314 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 12 Oct 2020 18:20:57 -0400 Subject: [PATCH] Cache account relationships in CoreData --- Pachyderm/Model/Relationship.swift | 2 + Tusker.xcodeproj/project.pbxproj | 4 ++ .../FollowAccountActivity.swift | 5 +- .../UnfollowAccountActivity.swift | 5 +- .../MastodonCachePersistentStore.swift | 35 +++++++++++ Tusker/CoreData/RelationshipMO.swift | 59 +++++++++++++++++++ .../Tusker.xcdatamodel/contents | 19 +++++- Tusker/Screens/Utilities/Previewing.swift | 8 ++- .../Profile Header/ProfileHeaderView.swift | 48 +++++++++++---- 9 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 Tusker/CoreData/RelationshipMO.swift diff --git a/Pachyderm/Model/Relationship.swift b/Pachyderm/Model/Relationship.swift index 38c046d2..7e93f651 100644 --- a/Pachyderm/Model/Relationship.swift +++ b/Pachyderm/Model/Relationship.swift @@ -18,6 +18,7 @@ public class Relationship: Decodable { public let followRequested: Bool public let domainBlocking: Bool public let showingReblogs: Bool + public let endorsed: Bool? private enum CodingKeys: String, CodingKey { case id @@ -29,5 +30,6 @@ public class Relationship: Decodable { case followRequested = "requested" case domainBlocking = "domain_blocking" case showingReblogs = "showing_reblogs" + case endorsed } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 0a3bc3c1..da451a96 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -260,6 +260,7 @@ D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; + D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; }; D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; }; D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; }; @@ -594,6 +595,7 @@ D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = ""; }; + D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = ""; }; D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = ""; }; D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = ""; }; D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = ""; }; @@ -893,6 +895,7 @@ D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */, D60E2F232442372B005F8713 /* StatusMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */, + D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, ); path = CoreData; @@ -1837,6 +1840,7 @@ D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, + D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, diff --git a/Tusker/Activities/Account Activities/FollowAccountActivity.swift b/Tusker/Activities/Account Activities/FollowAccountActivity.swift index 8c048f83..dde2773b 100644 --- a/Tusker/Activities/Account Activities/FollowAccountActivity.swift +++ b/Tusker/Activities/Account Activities/FollowAccountActivity.swift @@ -29,10 +29,13 @@ class FollowAccountActivity: AccountActivity { let request = Account.follow(account.id) mastodonController.run(request) { (response) in - if case .failure(_) = response { + switch response { + case .failure(_): // todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error) fatalError() + case let .success(relationship, _): + self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) } } } diff --git a/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift b/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift index bac7c884..87155ad6 100644 --- a/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift +++ b/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift @@ -29,10 +29,13 @@ class UnfollowAccountActivity: AccountActivity { let request = Account.unfollow(account.id) mastodonController.run(request) { (response) in - if case .failure(_) = response { + switch response { + case .failure(_): // todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error) fatalError() + case let .success(relationship, _): + self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) } } } diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 9b491112..cf6bac49 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -26,6 +26,7 @@ class MastodonCachePersistentStore: NSPersistentContainer { let statusSubject = PassthroughSubject() let accountSubject = PassthroughSubject() + let relationshipSubject = PassthroughSubject() init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { if transient { @@ -136,6 +137,40 @@ class MastodonCachePersistentStore: NSPersistentContainer { } } + func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? { + let context = context ?? viewContext + let request: NSFetchRequest = RelationshipMO.fetchRequest() + request.predicate = NSPredicate(format: "accountID = %@", id) + request.fetchLimit = 1 + if let result = try? context.fetch(request), let relationship = result.first { + return relationship + } else { + return nil + } + } + + @discardableResult + private func upsert(relationship: Relationship) -> RelationshipMO { + if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) { + relationshipMO.updateFrom(apiRelationship: relationship, container: self) + return relationshipMO + } else { + let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext) + return relationshipMO + } + } + + func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) { + backgroundContext.perform { + let relationshipMO = self.upsert(relationship: relationship) + if self.backgroundContext.hasChanges { + try! self.backgroundContext.save() + } + completion?(relationshipMO) + self.relationshipSubject.send(relationship.id) + } + } + func addAll(accounts: [Account], completion: (() -> Void)? = nil) { backgroundContext.perform { accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) } diff --git a/Tusker/CoreData/RelationshipMO.swift b/Tusker/CoreData/RelationshipMO.swift new file mode 100644 index 00000000..175287bc --- /dev/null +++ b/Tusker/CoreData/RelationshipMO.swift @@ -0,0 +1,59 @@ +// +// RelationshipMO.swift +// Tusker +// +// Created by Shadowfacts on 10/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm + +@objc(RelationshipMO) +public final class RelationshipMO: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Relationship") + } + + @NSManaged public var accountID: String + @NSManaged public var blocking: Bool + @NSManaged public var domainBlocking: Bool + @NSManaged public var endorsed: Bool + @NSManaged public var followedBy: Bool + @NSManaged public var following: Bool + @NSManaged public var muting: Bool + @NSManaged public var mutingNotifications: Bool + @NSManaged public var requested: Bool + @NSManaged public var showingReblogs: Bool + @NSManaged public var account: AccountMO? + +} + +extension RelationshipMO { + convenience init(apiRelationship relationship: Relationship, container: MastodonCachePersistentStore, context: NSManagedObjectContext) { + self.init(context: context) + self.updateFrom(apiRelationship: relationship, container: container) + } + + func updateFrom(apiRelationship relationship: Relationship, container: MastodonCachePersistentStore) { + guard let context = managedObjectContext else { + // we have been deleted, don't bother updating + return + } + + self.accountID = relationship.id + self.blocking = relationship.blocking + self.domainBlocking = relationship.domainBlocking + self.endorsed = relationship.endorsed ?? false + self.followedBy = relationship.followedBy + self.following = relationship.following + self.muting = relationship.muting + self.mutingNotifications = relationship.mutingNotifications + self.requested = relationship.followRequested + self.showingReblogs = relationship.showingReblogs + + self.account = container.account(for: relationship.id, in: context) + } +} diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 2af9efec..00a21a5c 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -20,12 +20,26 @@ + + + + + + + + + + + + + + @@ -59,7 +73,8 @@ - + + \ No newline at end of file diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 14eb7f8d..b6c0a82a 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -71,7 +71,13 @@ extension MenuPreviewProvider { elementHandler([ self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in let request = (following ? Account.unfollow : Account.follow)(accountID) - mastodonController.run(request) { (_) in + mastodonController.run(request) { (response) in + switch response { + case .failure(_): + fatalError() + case let .success(relationship, _): + mastodonController.persistentContainer.addOrUpdate(relationship: relationship) + } } }) ]) diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.swift b/Tusker/Views/Profile Header/ProfileHeaderView.swift index 7c4b95d2..190f24b3 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderView.swift @@ -23,7 +23,11 @@ class ProfileHeaderView: UIView { return nib.instantiate(withOwner: nil, options: nil).first as! ProfileHeaderView } - weak var delegate: ProfileHeaderViewDelegate? + weak var delegate: ProfileHeaderViewDelegate? { + didSet { + createObservers() + } + } var mastodonController: MastodonController! { delegate?.apiController } @IBOutlet weak var headerImageView: UIImageView! @@ -41,10 +45,10 @@ class ProfileHeaderView: UIView { var accountID: String! - var avatarRequest: ImageCache.Request? - var headerRequest: ImageCache.Request? + private var avatarRequest: ImageCache.Request? + private var headerRequest: ImageCache.Request? - private var accountUpdater: Cancellable? + private var cancellables = [AnyCancellable]() deinit { avatarRequest?.cancel() @@ -74,6 +78,22 @@ class ProfileHeaderView: UIView { } } + private func createObservers() { + cancellables = [] + + mastodonController.persistentContainer.accountSubject + .filter { [weak self] in $0 == self?.accountID } + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.updateUI(for: $0) } + .store(in: &cancellables) + + mastodonController.persistentContainer.relationshipSubject + .filter { [weak self] in $0 == self?.accountID } + .receive(on: DispatchQueue.main) + .sink { [weak self] (_) in self?.updateRelationship() } + .store(in: &cancellables) + } + func updateUI(for accountID: String) { self.accountID = accountID @@ -111,6 +131,9 @@ class ProfileHeaderView: UIView { // don't show relationship label for the user's own account if accountID != mastodonController.account?.id { + // while fetching the most up-to-date, show the current data (if any) + updateRelationship() + let request = Client.getRelationships(accounts: [accountID]) mastodonController.run(request) { [weak self] (response) in guard let self = self, @@ -118,9 +141,7 @@ class ProfileHeaderView: UIView { let relationship = results.first else { return } - DispatchQueue.main.async { - self.followsYouLabel.isHidden = !relationship.followedBy - } + self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) } } @@ -152,13 +173,14 @@ class ProfileHeaderView: UIView { nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true } - - if accountUpdater == nil { - accountUpdater = mastodonController.persistentContainer.accountSubject - .filter { [weak self] in $0 == self?.accountID } - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.updateUI(for: $0) } + } + + private func updateRelationship() { + guard let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else { + return } + + followsYouLabel.isHidden = !relationship.followedBy } @objc private func updateUIForPreferences() {