Move saved instances and hashtags to CoreData

This commit is contained in:
Shadowfacts 2022-05-10 22:57:46 -04:00
parent ed0643c4ad
commit d3187ce2c4
15 changed files with 355 additions and 120 deletions

View File

@ -237,6 +237,9 @@
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */; }; D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */; };
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366A281EE77E00237D0E /* PollVoteButton.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 */; }; D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; };
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; };
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.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 = "<group>"; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarMyProfileCollectionViewCell.swift; sourceTree = "<group>"; }; D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarMyProfileCollectionViewCell.swift; sourceTree = "<group>"; };
D6B9366A281EE77E00237D0E /* PollVoteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteButton.swift; sourceTree = "<group>"; }; D6B9366A281EE77E00237D0E /* PollVoteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteButton.swift; sourceTree = "<group>"; };
D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = "<group>"; };
D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = "<group>"; };
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = "<group>"; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; }; D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; };
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; };
@ -847,7 +853,10 @@
D60E2F232442372B005F8713 /* StatusMO.swift */, D60E2F232442372B005F8713 /* StatusMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
); );
path = CoreData; path = CoreData;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1793,6 +1802,8 @@
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */, D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */, D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
@ -1847,6 +1858,7 @@
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import CrashReporter import CrashReporter
import CoreData
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -27,6 +28,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
AudioSessionHelper.setDefault() 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 return true
} }

View File

@ -50,6 +50,8 @@ class MastodonCachePersistentStore: NSPersistentContainer {
fatalError("Unable to load persistent store: \(error)") 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? { 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<NSManagedObject> {
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<NSManagedObject> {
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
}
} }

View File

@ -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<T: NSFetchRequestResult>(for request: NSFetchRequest<T>) -> Bool {
switch try? count(for: request) {
case nil, 0, NSNotFound:
return false
default:
return true
}
}
}

View File

@ -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<SavedHashtag> {
return NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
}
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
let req = NSFetchRequest<SavedHashtag>(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
}
}

View File

@ -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<SavedInstance> {
return NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
}
@nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest<SavedInstance> {
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
}
}

View File

@ -40,6 +40,23 @@
<attribute name="showingReblogs" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="showingReblogs" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
</entity> </entity>
<entity name="SavedHashtag" representedClassName="SavedHashtag" syncable="YES">
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="name"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="SavedInstance" representedClassName="SavedInstance" syncable="YES">
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="url"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES"> <entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/> <attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/> <attribute name="attachmentsData" attributeType="Binary"/>
@ -77,7 +94,9 @@
</entity> </entity>
<elements> <elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/> <element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/> <element name="Relationship" positionX="63" positionY="135" width="128" height="194"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="449"/> <element name="Status" positionX="-63" positionY="-18" width="128" height="449"/>
<element name="SavedInstance" positionX="63" positionY="144" width="128" height="44"/>
<element name="SavedHashtag" positionX="72" positionY="153" width="128" height="59"/>
</elements> </elements>
</model> </model>

View File

@ -8,101 +8,68 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import CoreData
class SavedDataManager: Codable { 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 documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist") private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist")
static func save() { static func load() -> SavedDataManager? {
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 {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: archiveURL),
let savedHashtagsManager = try? decoder.decode(Self.self, from: data) { let savedHashtagsManager = try? decoder.decode(Self.self, from: data) {
return savedHashtagsManager return savedHashtagsManager
} }
return SavedDataManager() return nil
}
static func destroy() throws {
try FileManager.default.removeItem(at: archiveURL)
} }
private init() {} private init() {}
private var savedHashtags: [String: [Hashtag]] = [:] { private(set) var savedHashtags: [String: [Hashtag]] = [:]
didSet { private(set) var savedInstances: [String: [URL]] = [:]
SavedDataManager.save()
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) var accountIDs: Set<String> {
} var s = Set<String>()
savedHashtags.keys.forEach { s.insert($0) }
savedInstances.keys.forEach { s.insert($0) }
return s
} }
private var savedInstances: [String: [URL]] = [:] { private func save() {
didSet { let encoder = PropertyListEncoder()
SavedDataManager.save() let data = try? encoder.encode(self)
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) try? data?.write(to: SavedDataManager.archiveURL, options: .noFileProtection)
}
} }
func sortedHashtags(for account: LocalData.UserAccountInfo) -> [Hashtag] { func migrateToCoreData(accountID: String, context: NSManagedObjectContext) throws {
if let hashtags = savedHashtags[account.id] { var changed = false
return hashtags.sorted(by: { $0.name < $1.name })
} else { if let hashtags = savedHashtags[accountID] {
return [] 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
} }
func isSaved(hashtag: Hashtag, for account: LocalData.UserAccountInfo) -> Bool { if let instances = savedInstances[accountID] {
return savedHashtags[account.id]?.contains(hashtag) ?? false 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 add(hashtag: Hashtag, for account: LocalData.UserAccountInfo) { if changed {
if isSaved(hashtag: hashtag, for: account) { save()
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
} }
} }
} }

View File

@ -376,7 +376,8 @@ struct ComposeAutocompleteHashtagsView: View {
} }
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { 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) hashtags = (searchResults + savedTags + trendingTags)
.map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in

View File

@ -86,7 +86,9 @@ class AddSavedHashtagViewController: EnhancedTableViewController {
} }
private func selectHashtag(_ hashtag: Hashtag) { 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) presentingViewController!.dismiss(animated: true)
} }

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
import CoreData
class ExploreViewController: UIViewController, UICollectionViewDelegate { class ExploreViewController: UIViewController, UICollectionViewDelegate {
@ -134,8 +135,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
private func applyInitialSnapshot() { private func applyInitialSnapshot() {
let account = mastodonController.accountInfo!
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover }) snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks], toSection: .bookmarks) snapshot.appendItems([.bookmarks], toSection: .bookmarks)
@ -144,9 +143,15 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
addDiscoverSection(to: &snapshot) addDiscoverSection(to: &snapshot)
} }
snapshot.appendItems([.addList], toSection: .lists) 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([.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) snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot, animatingDifferences: false) 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() { @objc private func savedHashtagsChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) 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) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
dataSource.apply(snapshot) dataSource.apply(snapshot)
} }
@objc private func savedInstancesChanged() { @objc private func savedInstancesChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances)) 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) snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot) dataSource.apply(snapshot)
} }
@ -249,13 +280,19 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
func removeSavedHashtag(_ hashtag: Hashtag) { func removeSavedHashtag(_ hashtag: Hashtag) {
let account = mastodonController.accountInfo! let context = mastodonController.persistentContainer.viewContext
SavedDataManager.shared.remove(hashtag: hashtag, for: account) if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(hashtag)
try! context.save()
}
} }
func removeSavedInstance(_ instanceURL: URL) { func removeSavedInstance(_ instanceURL: URL) {
let account = mastodonController.accountInfo! let context = mastodonController.persistentContainer.viewContext
SavedDataManager.shared.remove(instance: instanceURL, for: account) if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first {
context.delete(instance)
try! context.save()
}
} }
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {

View File

@ -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() { @objc private func reloadSavedHashtags() {
let selected = collectionView.indexPathsForSelectedItems?.first let selected = collectionView.indexPathsForSelectedItems?.first
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>() var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
hashtagsSnapshot.append([.savedHashtagsHeader]) hashtagsSnapshot.append([.savedHashtagsHeader])
hashtagsSnapshot.expand([.savedHashtagsHeader]) hashtagsSnapshot.expand([.savedHashtagsHeader])
let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) let hashtags = fetchSavedHashtags().map {
hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader) Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader)
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader) hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) { self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) {
if let selected = selected { if let selected = selected {
@ -240,8 +264,10 @@ class MainSidebarViewController: UIViewController {
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>() var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
instancesSnapshot.append([.savedInstancesHeader]) instancesSnapshot.append([.savedInstancesHeader])
instancesSnapshot.expand([.savedInstancesHeader]) instancesSnapshot.expand([.savedInstancesHeader])
let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!) let instances = fetchSavedInstances().map {
instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader) Item.savedInstance($0.url)
}
instancesSnapshot.append(instances, to: .savedInstancesHeader)
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader) instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) { self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) {
if let selected = selected { if let selected = selected {
@ -555,6 +581,7 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) { func didSaveInstance(url: URL) {
dismiss(animated: true) { dismiss(animated: true) {
self.select(item: .savedInstance(url), animated: true)
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url)) self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
} }
} }

View File

@ -15,13 +15,17 @@ class HashtagTimelineViewController: TimelineTableViewController {
var toggleSaveButton: UIBarButtonItem! var toggleSaveButton: UIBarButtonItem!
var toggleSaveButtonTitle: String { var toggleSaveButtonTitle: String {
if SavedDataManager.shared.isSaved(hashtag: hashtag, for: mastodonController.accountInfo!) { if isHashtagSaved {
return NSLocalizedString("Unsave", comment: "unsave hashtag button") return NSLocalizedString("Unsave", comment: "unsave hashtag button")
} else { } else {
return NSLocalizedString("Save", comment: "save hashtag button") 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) { init(for hashtag: Hashtag, mastodonController: MastodonController) {
self.hashtag = hashtag self.hashtag = hashtag
@ -48,11 +52,13 @@ class HashtagTimelineViewController: TimelineTableViewController {
// MARK: - Interaction // MARK: - Interaction
@objc func toggleSaveButtonPressed() { @objc func toggleSaveButtonPressed() {
if SavedDataManager.shared.isSaved(hashtag: hashtag, for: mastodonController.accountInfo!) { let context = mastodonController.persistentContainer.viewContext
SavedDataManager.shared.remove(hashtag: hashtag, for: mastodonController.accountInfo!) if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(existing)
} else { } else {
SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!) _ = SavedHashtag(hashtag: hashtag, context: context)
} }
try! context.save()
} }
} }

View File

@ -22,9 +22,13 @@ class InstanceTimelineViewController: TimelineTableViewController {
let instanceURL: URL let instanceURL: URL
let instanceMastodonController: MastodonController let instanceMastodonController: MastodonController
var toggleSaveButton: UIBarButtonItem! private var toggleSaveButton: UIBarButtonItem!
var toggleSaveButtonTitle: String {
if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { 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") return NSLocalizedString("Unsave", comment: "unsave instance button")
} else { } else {
return NSLocalizedString("Save", comment: "save instance button") return NSLocalizedString("Save", comment: "save instance button")
@ -81,13 +85,16 @@ class InstanceTimelineViewController: TimelineTableViewController {
// MARK: - Interaction // MARK: - Interaction
@objc func toggleSaveButtonPressed() { @objc func toggleSaveButtonPressed() {
if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { let context = parentMastodonController!.persistentContainer.viewContext
SavedDataManager.shared.remove(instance: instanceURL, for: parentMastodonController!.accountInfo!) let existing = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first
if let existing = existing {
context.delete(existing)
delegate?.didUnsaveInstance(url: instanceURL) delegate?.didUnsaveInstance(url: instanceURL)
} else { } else {
SavedDataManager.shared.add(instance: instanceURL, for: parentMastodonController!.accountInfo!) _ = SavedInstance(url: instanceURL, context: context)
delegate?.didSaveInstance(url: instanceURL) delegate?.didSaveInstance(url: instanceURL)
} }
try? context.save()
} }
} }

View File

@ -97,18 +97,24 @@ extension MenuActionProvider {
} }
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] { func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
let account = mastodonController!.accountInfo! let actionsSection: [UIMenuElement]
let saved = SavedDataManager.shared.isSaved(hashtag: hashtag, for: account) if let mastodonController = mastodonController,
mastodonController.loggedIn {
let actionsSection = [ let context = mastodonController.persistentContainer.viewContext
createAction(identifier: "save", title: saved ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first
if saved { actionsSection = [
SavedDataManager.shared.remove(hashtag: hashtag, for: account) createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in
if let existing = existing {
context.delete(existing)
} else { } else {
SavedDataManager.shared.add(hashtag: hashtag, for: account) _ = SavedHashtag(hashtag: hashtag, context: context)
} }
try! context.save()
}) })
] ]
} else {
actionsSection = []
}
let shareSection = actionsForURL(hashtag.url, sourceView: sourceView) let shareSection = actionsForURL(hashtag.url, sourceView: sourceView)