From d3187ce2c4c608ab05c1ba1696b23e6c06980ec9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 10 May 2022 22:57:46 -0400 Subject: [PATCH] Move saved instances and hashtags to CoreData --- Tusker.xcodeproj/project.pbxproj | 12 ++ Tusker/AppDelegate.swift | 19 +++ .../MastodonCachePersistentStore.swift | 41 ++++++ .../NSManagedObjectContext+Helpers.swift | 20 +++ Tusker/CoreData/SavedHashtag.swift | 37 ++++++ Tusker/CoreData/SavedInstance.swift | 34 +++++ .../Tusker.xcdatamodel/contents | 21 ++- Tusker/SavedDataManager.swift | 125 +++++++----------- .../Compose/ComposeAutocompleteView.swift | 3 +- .../AddSavedHashtagViewController.swift | 4 +- .../Explore/ExploreViewController.swift | 61 +++++++-- .../Main/MainSidebarViewController.swift | 35 ++++- .../HashtagTimelineViewController.swift | 14 +- .../InstanceTimelineViewController.swift | 19 ++- Tusker/Screens/Utilities/Previewing.swift | 30 +++-- 15 files changed, 355 insertions(+), 120 deletions(-) create mode 100644 Tusker/CoreData/NSManagedObjectContext+Helpers.swift create mode 100644 Tusker/CoreData/SavedHashtag.swift create mode 100644 Tusker/CoreData/SavedInstance.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 911f97c6..5fdd702e 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -237,6 +237,9 @@ D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */; }; D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366A281EE77E00237D0E /* PollVoteButton.swift */; }; + D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; }; + D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; }; + D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; }; D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; @@ -579,6 +582,9 @@ D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = ""; }; D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarMyProfileCollectionViewCell.swift; sourceTree = ""; }; D6B9366A281EE77E00237D0E /* PollVoteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteButton.swift; sourceTree = ""; }; + D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = ""; }; + D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = ""; }; + D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = ""; }; D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = ""; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = ""; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = ""; }; @@ -847,7 +853,10 @@ D60E2F232442372B005F8713 /* StatusMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */, D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, + D6B9366E2828452F00237D0E /* SavedHashtag.swift */, + D6B9366C2828444F00237D0E /* SavedInstance.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, + D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, ); path = CoreData; sourceTree = ""; @@ -1793,6 +1802,8 @@ D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, + D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */, + D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */, @@ -1847,6 +1858,7 @@ D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, + D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index bdfb20e4..eaf3e068 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -8,6 +8,7 @@ import UIKit import CrashReporter +import CoreData @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -26,6 +27,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AudioSessionHelper.disable() AudioSessionHelper.setDefault() } + + if let oldSavedData = SavedDataManager.load() { + do { + for account in oldSavedData.accountIDs { + guard let account = LocalData.shared.getAccount(id: account) else { + continue + } + let controller = MastodonController.getForAccount(account) + try oldSavedData.migrateToCoreData(accountID: account.id, context: controller.persistentContainer.viewContext) + if controller.persistentContainer.viewContext.hasChanges { + try controller.persistentContainer.viewContext.save() + } + } + try SavedDataManager.destroy() + } catch { + // no-op + } + } return true } diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index ebdd12fa..95c7f231 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -50,6 +50,8 @@ class MastodonCachePersistentStore: NSPersistentContainer { fatalError("Unable to load persistent store: \(error)") } } + + NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) } func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? { @@ -215,4 +217,43 @@ class MastodonCachePersistentStore: NSPersistentContainer { } } + @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { + let changes = hasChangedSavedHashtagsOrInstances(notification) + if changes.hashtags { + NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) + } + if changes.instances { + NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) + } + } + + private func hasChangedSavedHashtagsOrInstances(_ notification: Foundation.Notification) -> (hashtags: Bool, instances: Bool) { + var changes: (hashtags: Bool, instances: Bool) = (false, false) + if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set { + for object in inserted { + if object is SavedHashtag { + changes.hashtags = true + } else if object is SavedInstance { + changes.instances = true + } + if changes.hashtags && changes.instances { + return changes + } + } + } + if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set { + for object in deleted { + if object is SavedHashtag { + changes.hashtags = true + } else if object is SavedInstance { + changes.instances = true + } + if changes.hashtags && changes.instances { + return changes + } + } + } + return changes + } + } diff --git a/Tusker/CoreData/NSManagedObjectContext+Helpers.swift b/Tusker/CoreData/NSManagedObjectContext+Helpers.swift new file mode 100644 index 00000000..033c275d --- /dev/null +++ b/Tusker/CoreData/NSManagedObjectContext+Helpers.swift @@ -0,0 +1,20 @@ +// +// NSManagedObjectContext+Helpers.swift +// Tusker +// +// Created by Shadowfacts on 5/9/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import CoreData + +extension NSManagedObjectContext { + func objectExists(for request: NSFetchRequest) -> Bool { + switch try? count(for: request) { + case nil, 0, NSNotFound: + return false + default: + return true + } + } +} diff --git a/Tusker/CoreData/SavedHashtag.swift b/Tusker/CoreData/SavedHashtag.swift new file mode 100644 index 00000000..f4e7bab9 --- /dev/null +++ b/Tusker/CoreData/SavedHashtag.swift @@ -0,0 +1,37 @@ +// +// SavedHashtag.swift +// Tusker +// +// Created by Shadowfacts on 5/8/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm + +@objc(SavedHashtag) +public final class SavedHashtag: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "SavedHashtag") + } + + @nonobjc public class func fetchRequest(name: String) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "SavedHashtag") + req.predicate = NSPredicate(format: "name = %@", name) + return req + } + + @NSManaged public var name: String + @NSManaged public var url: URL + +} + +extension SavedHashtag { + convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { + self.init(context: context) + self.name = hashtag.name + self.url = hashtag.url + } +} diff --git a/Tusker/CoreData/SavedInstance.swift b/Tusker/CoreData/SavedInstance.swift new file mode 100644 index 00000000..80c7c603 --- /dev/null +++ b/Tusker/CoreData/SavedInstance.swift @@ -0,0 +1,34 @@ +// +// SavedInstance.swift +// Tusker +// +// Created by Shadowfacts on 5/8/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData + +@objc(SavedInstance) +public final class SavedInstance: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "SavedInstance") + } + + @nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest { + let req = fetchRequest() + req.predicate = NSPredicate(format: "url = %@", url as NSURL) + return req + } + + @NSManaged public var url: URL + +} + +extension SavedInstance { + convenience init(url: URL, context: NSManagedObjectContext) { + self.init(context: context) + self.url = url + } +} diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index b4d726c2..f60d79ce 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -40,6 +40,23 @@ + + + + + + + + + + + + + + + + + @@ -77,7 +94,9 @@ - + + + \ No newline at end of file diff --git a/Tusker/SavedDataManager.swift b/Tusker/SavedDataManager.swift index 6d4696f9..8614e52e 100644 --- a/Tusker/SavedDataManager.swift +++ b/Tusker/SavedDataManager.swift @@ -8,101 +8,68 @@ import Foundation import Pachyderm +import CoreData class SavedDataManager: Codable { - private(set) static var shared: SavedDataManager = load() - private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist") - static func save() { - DispatchQueue.global(qos: .utility).async { - let encoder = PropertyListEncoder() - let data = try? encoder.encode(shared) - try? data?.write(to: archiveURL, options: .noFileProtection) - } - } - - static func load() -> SavedDataManager { + static func load() -> SavedDataManager? { let decoder = PropertyListDecoder() if let data = try? Data(contentsOf: archiveURL), let savedHashtagsManager = try? decoder.decode(Self.self, from: data) { return savedHashtagsManager } - return SavedDataManager() + return nil + } + + static func destroy() throws { + try FileManager.default.removeItem(at: archiveURL) } private init() {} - private var savedHashtags: [String: [Hashtag]] = [:] { - didSet { - SavedDataManager.save() - NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) + private(set) var savedHashtags: [String: [Hashtag]] = [:] + private(set) var savedInstances: [String: [URL]] = [:] + + var accountIDs: Set { + var s = Set() + savedHashtags.keys.forEach { s.insert($0) } + savedInstances.keys.forEach { s.insert($0) } + return s + } + + private func save() { + let encoder = PropertyListEncoder() + let data = try? encoder.encode(self) + try? data?.write(to: SavedDataManager.archiveURL, options: .noFileProtection) + } + + func migrateToCoreData(accountID: String, context: NSManagedObjectContext) throws { + var changed = false + + if let hashtags = savedHashtags[accountID] { + let objects = hashtags.map { + ["url": $0.url, "name": $0.name] + } + let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects) + try context.execute(hashtagsReq) + savedHashtags.removeValue(forKey: accountID) + changed = true } - } - - private var savedInstances: [String: [URL]] = [:] { - didSet { - SavedDataManager.save() - NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) + + if let instances = savedInstances[accountID] { + let objects = instances.map { + ["url": $0] + } + let instancesReq = NSBatchInsertRequest(entity: SavedInstance.entity(), objects: objects) + try context.execute(instancesReq) + savedInstances.removeValue(forKey: accountID) + changed = true } - } - - func sortedHashtags(for account: LocalData.UserAccountInfo) -> [Hashtag] { - if let hashtags = savedHashtags[account.id] { - return hashtags.sorted(by: { $0.name < $1.name }) - } else { - return [] - } - } - - func isSaved(hashtag: Hashtag, for account: LocalData.UserAccountInfo) -> Bool { - return savedHashtags[account.id]?.contains(hashtag) ?? false - } - - func add(hashtag: Hashtag, for account: LocalData.UserAccountInfo) { - if isSaved(hashtag: hashtag, for: account) { - return - } - if var saved = savedHashtags[account.id] { - saved.append(hashtag) - savedHashtags[account.id] = saved - } else { - savedHashtags[account.id] = [hashtag] - } - } - - func remove(hashtag: Hashtag, for account: LocalData.UserAccountInfo) { - guard isSaved(hashtag: hashtag, for: account) else { return } - if var saved = savedHashtags[account.id] { - saved.removeAll(where: { $0.name == hashtag.name }) - savedHashtags[account.id] = saved - } - } - - func savedInstances(for account: LocalData.UserAccountInfo) -> [URL] { - return savedInstances[account.id] ?? [] - } - - func isSaved(instance url: URL, for account: LocalData.UserAccountInfo) -> Bool { - return savedInstances[account.id]?.contains(url) ?? false - } - - func add(instance url: URL, for account: LocalData.UserAccountInfo) { - if isSaved(instance: url, for: account) { return } - if var saved = savedInstances[account.id] { - saved.append(url) - savedInstances[account.id] = saved - } else { - savedInstances[account.id] = [url] - } - } - - func remove(instance url: URL, for account: LocalData.UserAccountInfo) { - guard isSaved(instance: url, for: account) else { return } - if var saved = savedInstances[account.id] { - saved.removeAll(where: { $0 == url }) - savedInstances[account.id] = saved + + if changed { + save() } } } diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift index 5792624b..813b4777 100644 --- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift +++ b/Tusker/Screens/Compose/ComposeAutocompleteView.swift @@ -376,7 +376,8 @@ struct ComposeAutocompleteHashtagsView: View { } private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { - let savedTags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) + let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []) + .map { Hashtag(name: $0.name, url: $0.url) } hashtags = (searchResults + savedTags + trendingTags) .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in diff --git a/Tusker/Screens/Explore/AddSavedHashtagViewController.swift b/Tusker/Screens/Explore/AddSavedHashtagViewController.swift index 2ad81bd8..8f438abd 100644 --- a/Tusker/Screens/Explore/AddSavedHashtagViewController.swift +++ b/Tusker/Screens/Explore/AddSavedHashtagViewController.swift @@ -86,7 +86,9 @@ class AddSavedHashtagViewController: EnhancedTableViewController { } private func selectHashtag(_ hashtag: Hashtag) { - SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!) + let context = mastodonController.persistentContainer.viewContext + _ = SavedHashtag(hashtag: hashtag, context: context) + try! context.save() presentingViewController!.dismiss(animated: true) } diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index af15750b..393fc81c 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -9,6 +9,7 @@ import UIKit import Combine import Pachyderm +import CoreData class ExploreViewController: UIViewController, UICollectionViewDelegate { @@ -134,8 +135,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { } private func applyInitialSnapshot() { - let account = mastodonController.accountInfo! - var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(Section.allCases.filter { $0 != .discover }) snapshot.appendItems([.bookmarks], toSection: .bookmarks) @@ -144,9 +143,15 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { addDiscoverSection(to: &snapshot) } snapshot.appendItems([.addList], toSection: .lists) - snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) + let hashtags = fetchSavedHashtags().map { + Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) + } + snapshot.appendItems(hashtags, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) - snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances) + let instances = fetchSavedInstances().map { + Item.savedInstance($0.url) + } + snapshot.appendItems(instances, toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances) dataSource.apply(snapshot, animatingDifferences: false) @@ -190,20 +195,46 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { } } + @MainActor + private func fetchSavedHashtags() -> [SavedHashtag] { + let req = SavedHashtag.fetchRequest() + req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + do { + return try mastodonController.persistentContainer.viewContext.fetch(req) + } catch { + return [] + } + } + + @MainActor + private func fetchSavedInstances() -> [SavedInstance] { + let req = SavedInstance.fetchRequest() + req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] + do { + return try mastodonController.persistentContainer.viewContext.fetch(req) + } catch { + return [] + } + } + @objc private func savedHashtagsChanged() { - let account = mastodonController.accountInfo! var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) - snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) + let hashtags = fetchSavedHashtags().map { + Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) + } + snapshot.appendItems(hashtags, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) dataSource.apply(snapshot) } @objc private func savedInstancesChanged() { - let account = mastodonController.accountInfo! var snapshot = dataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances)) - snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances) + let instances = fetchSavedInstances().map { + Item.savedInstance($0.url) + } + snapshot.appendItems(instances, toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances) dataSource.apply(snapshot) } @@ -249,13 +280,19 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { } func removeSavedHashtag(_ hashtag: Hashtag) { - let account = mastodonController.accountInfo! - SavedDataManager.shared.remove(hashtag: hashtag, for: account) + let context = mastodonController.persistentContainer.viewContext + if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { + context.delete(hashtag) + try! context.save() + } } func removeSavedInstance(_ instanceURL: URL) { - let account = mastodonController.accountInfo! - SavedDataManager.shared.remove(instance: instanceURL, for: account) + let context = mastodonController.persistentContainer.viewContext + if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first { + context.delete(instance) + try! context.save() + } } private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index 40d79f26..8523d2c1 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -218,14 +218,38 @@ class MainSidebarViewController: UIViewController { } } + @MainActor + private func fetchSavedHashtags() -> [SavedHashtag] { + let req = SavedHashtag.fetchRequest() + req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + do { + return try mastodonController.persistentContainer.viewContext.fetch(req) + } catch { + return [] + } + } + + @MainActor + private func fetchSavedInstances() -> [SavedInstance] { + let req = SavedInstance.fetchRequest() + req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] + do { + return try mastodonController.persistentContainer.viewContext.fetch(req) + } catch { + return [] + } + } + @objc private func reloadSavedHashtags() { let selected = collectionView.indexPathsForSelectedItems?.first var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot() hashtagsSnapshot.append([.savedHashtagsHeader]) hashtagsSnapshot.expand([.savedHashtagsHeader]) - let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) - hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader) + let hashtags = fetchSavedHashtags().map { + Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) + } + hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader) hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader) self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) { if let selected = selected { @@ -240,8 +264,10 @@ class MainSidebarViewController: UIViewController { var instancesSnapshot = NSDiffableDataSourceSectionSnapshot() instancesSnapshot.append([.savedInstancesHeader]) instancesSnapshot.expand([.savedInstancesHeader]) - let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!) - instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader) + let instances = fetchSavedInstances().map { + Item.savedInstance($0.url) + } + instancesSnapshot.append(instances, to: .savedInstancesHeader) instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader) self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) { if let selected = selected { @@ -555,6 +581,7 @@ extension MainSidebarViewController: UICollectionViewDragDelegate { extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { func didSaveInstance(url: URL) { dismiss(animated: true) { + self.select(item: .savedInstance(url), animated: true) self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url)) } } diff --git a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift index ab8d5b6c..823a1f80 100644 --- a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift +++ b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift @@ -15,13 +15,17 @@ class HashtagTimelineViewController: TimelineTableViewController { var toggleSaveButton: UIBarButtonItem! var toggleSaveButtonTitle: String { - if SavedDataManager.shared.isSaved(hashtag: hashtag, for: mastodonController.accountInfo!) { + if isHashtagSaved { return NSLocalizedString("Unsave", comment: "unsave hashtag button") } else { return NSLocalizedString("Save", comment: "save hashtag button") } } + private var isHashtagSaved: Bool { + mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name)) + } + init(for hashtag: Hashtag, mastodonController: MastodonController) { self.hashtag = hashtag @@ -48,11 +52,13 @@ class HashtagTimelineViewController: TimelineTableViewController { // MARK: - Interaction @objc func toggleSaveButtonPressed() { - if SavedDataManager.shared.isSaved(hashtag: hashtag, for: mastodonController.accountInfo!) { - SavedDataManager.shared.remove(hashtag: hashtag, for: mastodonController.accountInfo!) + let context = mastodonController.persistentContainer.viewContext + if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { + context.delete(existing) } else { - SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!) + _ = SavedHashtag(hashtag: hashtag, context: context) } + try! context.save() } } diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index 235d9cba..3c475162 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -22,9 +22,13 @@ class InstanceTimelineViewController: TimelineTableViewController { let instanceURL: URL let instanceMastodonController: MastodonController - var toggleSaveButton: UIBarButtonItem! - var toggleSaveButtonTitle: String { - if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { + private var toggleSaveButton: UIBarButtonItem! + + private var isInstanceSaved: Bool { + parentMastodonController!.persistentContainer.viewContext.objectExists(for: SavedInstance.fetchRequest(url: instanceURL)) + } + private var toggleSaveButtonTitle: String { + if isInstanceSaved { return NSLocalizedString("Unsave", comment: "unsave instance button") } else { return NSLocalizedString("Save", comment: "save instance button") @@ -81,13 +85,16 @@ class InstanceTimelineViewController: TimelineTableViewController { // MARK: - Interaction @objc func toggleSaveButtonPressed() { - if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { - SavedDataManager.shared.remove(instance: instanceURL, for: parentMastodonController!.accountInfo!) + let context = parentMastodonController!.persistentContainer.viewContext + let existing = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first + if let existing = existing { + context.delete(existing) delegate?.didUnsaveInstance(url: instanceURL) } else { - SavedDataManager.shared.add(instance: instanceURL, for: parentMastodonController!.accountInfo!) + _ = SavedInstance(url: instanceURL, context: context) delegate?.didSaveInstance(url: instanceURL) } + try? context.save() } } diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 5e74bbe9..539746db 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -97,18 +97,24 @@ extension MenuActionProvider { } func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] { - let account = mastodonController!.accountInfo! - let saved = SavedDataManager.shared.isSaved(hashtag: hashtag, for: account) - - let actionsSection = [ - createAction(identifier: "save", title: saved ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in - if saved { - SavedDataManager.shared.remove(hashtag: hashtag, for: account) - } else { - SavedDataManager.shared.add(hashtag: hashtag, for: account) - } - }) - ] + let actionsSection: [UIMenuElement] + if let mastodonController = mastodonController, + mastodonController.loggedIn { + let context = mastodonController.persistentContainer.viewContext + let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first + actionsSection = [ + createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in + if let existing = existing { + context.delete(existing) + } else { + _ = SavedHashtag(hashtag: hashtag, context: context) + } + try! context.save() + }) + ] + } else { + actionsSection = [] + } let shareSection = actionsForURL(hashtag.url, sourceView: sourceView)