diff --git a/Pachyderm/Sources/Pachyderm/Client.swift b/Pachyderm/Sources/Pachyderm/Client.swift index 45b54ae5..0316b2f7 100644 --- a/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Pachyderm/Sources/Pachyderm/Client.swift @@ -261,6 +261,10 @@ public class Client { return Request(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct])) } + public static func getFollowedHashtags() -> Request<[Hashtag]> { + return Request(method: .get, path: "/api/v1/followed_tags") + } + // MARK: - Lists public static func getLists() -> Request<[List]> { return Request<[List]>(method: .get, path: "/api/v1/lists") diff --git a/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift index 961c7c06..1f72f2ca 100644 --- a/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift +++ b/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift @@ -15,11 +15,14 @@ public class Hashtag: Codable { public let url: WebURL /// Only present when returned from the trending hashtags endpoint public let history: [History]? + /// Only present on Mastodon >= 4 and when logged in + public let following: Bool? public init(name: String, url: URL) { self.name = name self.url = WebURL(url)! self.history = nil + self.following = nil } public required init(from decoder: Decoder) throws { @@ -28,6 +31,7 @@ public class Hashtag: Codable { // pixelfed (possibly others) don't fully escape special characters in the hashtag url self.url = try container.decode(WebURL.self, forKey: .url) self.history = try container.decodeIfPresent([History].self, forKey: .history) + self.following = try container.decodeIfPresent(Bool.self, forKey: .following) } public func encode(to encoder: Encoder) throws { @@ -35,12 +39,14 @@ public class Hashtag: Codable { try container.encode(name, forKey: .name) try container.encode(url, forKey: .url) try container.encodeIfPresent(history, forKey: .history) + try container.encodeIfPresent(following, forKey: .following) } private enum CodingKeys: String, CodingKey { case name case url case history + case following } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index d6e1a296..eb7acc8a 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; }; D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; }; D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; }; + D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; @@ -416,6 +417,7 @@ D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = ""; }; D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = ""; }; D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; + D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; @@ -881,6 +883,7 @@ D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D6B9366E2828452F00237D0E /* SavedHashtag.swift */, D6B9366C2828444F00237D0E /* SavedInstance.swift */, + D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, ); @@ -1824,6 +1827,7 @@ 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, + D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */, D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, diff --git a/Tusker/API/InstanceFeatures.swift b/Tusker/API/InstanceFeatures.swift index 3c0db8de..935dc902 100644 --- a/Tusker/API/InstanceFeatures.swift +++ b/Tusker/API/InstanceFeatures.swift @@ -84,6 +84,14 @@ struct InstanceFeatures { } } + var canFollowHashtags: Bool { + if case .mastodon(_, .some(let version)) = instanceType { + return version >= Version(4, 0, 0) + } else { + return false + } + } + mutating func update(instance: Instance, nodeInfo: NodeInfo?) { let ver = instance.version.lowercased() if ver.contains("glitch") { diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index a43cd3d0..87eeef87 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -49,6 +49,7 @@ class MastodonController: ObservableObject { @Published private(set) var instanceFeatures = InstanceFeatures() @Published private(set) var lists: [List] = [] @Published private(set) var customEmojis: [Emoji]? + @Published private(set) var followedHashtags: [FollowedHashtag] = [] private var cancellables = Set() @@ -78,6 +79,15 @@ class MastodonController: ObservableObject { self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo) } .store(in: &cancellables) + + $instanceFeatures + .filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty } + .sink { [unowned self] _ in + Task { + await self.loadFollowedHashtags() + } + } + .store(in: &cancellables) } @discardableResult @@ -321,6 +331,20 @@ class MastodonController: ObservableObject { self.lists = new } + @MainActor + private func loadFollowedHashtags() async { + followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? [] + + let req = Client.getFollowedHashtags() + if let (hashtags, _) = try? await run(req) { + self.persistentContainer.updateFollowedHashtags(hashtags) { + if case .success(let hashtags) = $0 { + self.followedHashtags = hashtags + } + } + } + } + } private struct ListComparator: SortComparator { diff --git a/Tusker/CoreData/FollowedHashtag.swift b/Tusker/CoreData/FollowedHashtag.swift new file mode 100644 index 00000000..762c8596 --- /dev/null +++ b/Tusker/CoreData/FollowedHashtag.swift @@ -0,0 +1,38 @@ +// +// FollowedHashtag.swift +// Tusker +// +// Created by Shadowfacts on 11/29/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm +import WebURLFoundationExtras + +@objc(FollowedHashtag) +public final class FollowedHashtag: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "FollowedHashtag") + } + + @nonobjc public class func fetchRequest(name: String) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "FollowedHashtag") + req.predicate = NSPredicate(format: "name = %@", name) + return req + } + + @NSManaged public var name: String + @NSManaged public var url: URL + +} + +extension FollowedHashtag { + convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { + self.init(context: context) + self.name = hashtag.name + self.url = URL(hashtag.url)! + } +} diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 1bf1b6e6..e8846675 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -267,6 +267,38 @@ class MastodonCachePersistentStore: NSPersistentContainer { } } + func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) { + viewContext.perform { + do { + var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest()) + + let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID) + if !toDelete.isEmpty { + try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete)) + } + + for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) { + let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext) + all.append(mo) + } + + self.save(context: self.viewContext) + completion(.success(all)) + } catch { + completion(.failure(error)) + } + } + } + + func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool { + do { + let req = FollowedHashtag.fetchRequest(name: name) + return try viewContext.count(for: req) > 0 + } catch { + return false + } + } + @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { let changes = hasChangedSavedHashtagsOrInstances(notification) if changes.hashtags { diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 9ba83c7a..5c636a15 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -28,6 +28,10 @@ + + + +