diff --git a/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift index 1f72f2ca..4e7e699f 100644 --- a/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift +++ b/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift @@ -42,6 +42,14 @@ public class Hashtag: Codable { try container.encodeIfPresent(following, forKey: .following) } + public static func follow(name: String) -> Request { + return Request(method: .post, path: "/api/v1/tags/\(name)/follow") + } + + public static func unfollow(name: String) -> Request { + return Request(method: .post, path: "/api/v1/tags/\(name)/unfollow") + } + private enum CodingKeys: String, CodingKey { case name case url diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 4acf06ff..fc9bbc7b 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 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 */; }; + D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.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 */; }; @@ -418,6 +419,7 @@ 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 = ""; }; + D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.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 = ""; }; @@ -1529,6 +1531,7 @@ D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, + D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */, ); path = API; sourceTree = ""; @@ -1998,6 +2001,7 @@ D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, + D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 87eeef87..5343b765 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -333,7 +333,7 @@ class MastodonController: ObservableObject { @MainActor private func loadFollowedHashtags() async { - followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? [] + updateFollowedHashtags() let req = Client.getFollowedHashtags() if let (hashtags, _) = try? await run(req) { @@ -345,6 +345,11 @@ class MastodonController: ObservableObject { } } + @MainActor + func updateFollowedHashtags() { + followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? [] + } + } private struct ListComparator: SortComparator { diff --git a/Tusker/API/ToggleFollowHashtagService.swift b/Tusker/API/ToggleFollowHashtagService.swift new file mode 100644 index 00000000..e3b8c00f --- /dev/null +++ b/Tusker/API/ToggleFollowHashtagService.swift @@ -0,0 +1,67 @@ +// +// ToggleFollowHashtagService.swift +// Tusker +// +// Created by Shadowfacts on 11/29/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class ToggleFollowHashtagService { + + private let hashtag: Hashtag + private let mastodonController: MastodonController + private let presenter: any TuskerNavigationDelegate + + init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) { + self.hashtag = hashtag + self.mastodonController = presenter.apiController + self.presenter = presenter + } + + func toggleFollow() async { + let context = mastodonController.persistentContainer.viewContext + var config: ToastConfiguration + if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtag.name }) { + do { + let req = Hashtag.unfollow(name: hashtag.name) + _ = try await mastodonController.run(req) + + context.delete(existing) + mastodonController.updateFollowedHashtags() + + config = ToastConfiguration(title: "Unfollowed Hashtag") + config.systemImageName = "checkmark" + config.dismissAutomaticallyAfter = 2 + } catch { + config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.toggleFollow() + } + } + } else { + do { + let req = Hashtag.follow(name: hashtag.name) + let (hashtag, _) = try await mastodonController.run(req) + + _ = FollowedHashtag(hashtag: hashtag, context: context) + mastodonController.updateFollowedHashtags() + + config = ToastConfiguration(title: "Followed Hashtag") + config.systemImageName = "checkmark" + config.dismissAutomaticallyAfter = 2 + } catch { + config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.toggleFollow() + } + } + } + presenter.showToast(configuration: config, animated: true) + mastodonController.persistentContainer.save(context: context) + } + +} diff --git a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift index acfebb41..cacd4c2d 100644 --- a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift +++ b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift @@ -14,18 +14,15 @@ class HashtagTimelineViewController: TimelineViewController { let hashtag: Hashtag var toggleSaveButton: UIBarButtonItem! - var toggleSaveButtonTitle: String { - 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)) } + private var isHashtagFollowed: Bool { + mastodonController.followedHashtags.contains(where: { $0.name == hashtag.name }) + } + init(for hashtag: Hashtag, mastodonController: MastodonController) { self.hashtag = hashtag @@ -39,19 +36,16 @@ class HashtagTimelineViewController: TimelineViewController { override func viewDidLoad() { super.viewDidLoad() - toggleSaveButton = UIBarButtonItem(title: toggleSaveButtonTitle, style: .plain, target: self, action: #selector(toggleSaveButtonPressed)) - navigationItem.rightBarButtonItem = toggleSaveButton - - NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) + let menu = UIMenu(children: [ + // uncached so that the saved/followed updates every time + UIDeferredMenuElement.uncached({ [unowned self] elementHandler in + elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!))) + }) + ]) + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu) } - @objc func savedHashtagsChanged() { - toggleSaveButton.title = toggleSaveButtonTitle - } - - // MARK: - Interaction - - @objc func toggleSaveButtonPressed() { + private func toggleSave() { let context = mastodonController.persistentContainer.viewContext if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { context.delete(existing) @@ -60,5 +54,11 @@ class HashtagTimelineViewController: TimelineViewController { } mastodonController.persistentContainer.save(context: context) } + + private func toggleFollow() { + Task { + await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow() + } + } } diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 2d310198..85e9ffb5 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -106,13 +106,16 @@ extension MenuActionProvider { } func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] { - let actionsSection: [UIMenuElement] + var actionsSection: [UIMenuElement] = [] if let mastodonController = mastodonController, mastodonController.loggedIn { + let name = hashtag.name.lowercased() let context = mastodonController.persistentContainer.viewContext - let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first + let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first + let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker" + let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus") actionsSection = [ - createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in + UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in if let existing = existing { context.delete(existing) } else { @@ -121,8 +124,16 @@ extension MenuActionProvider { mastodonController.persistentContainer.save(context: context) }) ] - } else { - actionsSection = [] + if mastodonController.instanceFeatures.canFollowHashtags { + let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name }) + let subtitle = "Posts tagged with followed hashtags appear in your Home timeline" + let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus") + actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in + Task { + await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow() + } + }) + } } let shareSection: [UIMenuElement]