diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index b12fe8ac..f50eec41 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -483,6 +483,12 @@ public class Client { ]) } + // MARK: - Hashtags + /// Requires Mastodon 4.0.0+ + public static func getHashtag(name: String) -> Request { + return Request(method: .get, path: "/api/v1/tags/\(name)") + } + } extension Client { diff --git a/Tusker/API/ToggleFollowHashtagService.swift b/Tusker/API/ToggleFollowHashtagService.swift index 38afa089..f554d373 100644 --- a/Tusker/API/ToggleFollowHashtagService.swift +++ b/Tusker/API/ToggleFollowHashtagService.swift @@ -12,12 +12,12 @@ import Pachyderm @MainActor class ToggleFollowHashtagService { - private let hashtag: Hashtag + private let hashtagName: String private let mastodonController: MastodonController private let presenter: any TuskerNavigationDelegate - init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) { - self.hashtag = hashtag + init(hashtagName: String, presenter: any TuskerNavigationDelegate) { + self.hashtagName = hashtagName self.mastodonController = presenter.apiController self.presenter = presenter } @@ -25,9 +25,9 @@ class ToggleFollowHashtagService { func toggleFollow() async { let context = mastodonController.persistentContainer.viewContext var config: ToastConfiguration - if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtag.name }) { + if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtagName }) { do { - let req = Hashtag.unfollow(name: hashtag.name) + let req = Hashtag.unfollow(name: hashtagName) _ = try await mastodonController.run(req) context.delete(existing) @@ -44,7 +44,7 @@ class ToggleFollowHashtagService { } } else { do { - let req = Hashtag.follow(name: hashtag.name) + let req = Hashtag.follow(name: hashtagName) let (hashtag, _) = try await mastodonController.run(req) _ = FollowedHashtag(hashtag: hashtag, context: context) diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index 507c1586..81b09431 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -106,7 +106,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel private func timelineViewController(for timeline: Timeline, mastodonController: MastodonController) -> UIViewController { switch timeline { - // todo: hashtag controllers need whole objects which must be fetched asynchronously, and that endpoint only exists in mastodon v4 + case .tag(hashtag: let name): + return HashtagTimelineViewController(forNamed: name, mastodonController: mastodonController) + case .list(id: let id): let req = ListMO.fetchRequest(id: id) if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first { diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index 07f9e6e3..38a57eee 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -127,6 +127,11 @@ class MainSidebarViewController: UIViewController { itemLastSelectedTimestamps[item] = Date() } + func hasItem(_ item: Item) -> Bool { + loadViewIfNeeded() + return dataSource.snapshot().itemIdentifiers.contains(item) + } + private func createDataSource() -> UICollectionViewDiffableDataSource { let listCell = UICollectionView.CellRegistration { (cell, indexPath, item) in var config = cell.defaultContentConfiguration() @@ -212,10 +217,10 @@ class MainSidebarViewController: UIViewController { let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] var items = saved.map { - Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) + Item.savedHashtag($0.name) } for followed in followed where !saved.contains(where: { $0.name == followed.name }) { - items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url))) + items.append(.savedHashtag(followed.name)) } items = items.uniques() items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) @@ -315,8 +320,8 @@ class MainSidebarViewController: UIViewController { return UserActivityManager.myProfileActivity(accountID: id) case let .list(list): return UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: id) - case let .savedHashtag(tag): - return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: id) + case let .savedHashtag(name): + return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: name), accountID: id) case .savedInstance(_): // todo: show timeline activity doesn't work for public timelines return nil @@ -347,7 +352,7 @@ extension MainSidebarViewController { case tab(MainTabBarViewController.Tab) case explore, bookmarks, favorites case listsHeader, list(List), addList - case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag + case savedHashtagsHeader, savedHashtag(String), addSavedHashtag case savedInstancesHeader, savedInstance(URL), addSavedInstance var title: String { @@ -368,8 +373,8 @@ extension MainSidebarViewController { return "New List..." case .savedHashtagsHeader: return "Hashtags" - case let .savedHashtag(hashtag): - return hashtag.name + case let .savedHashtag(name): + return name case .addSavedHashtag: return "Add Hashtag..." case .savedInstancesHeader: diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 8d7e0876..a2f51f76 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -339,8 +339,8 @@ extension MainSplitViewController: UISplitViewControllerDelegate { exploreItem = .favorites case let listVC as ListTimelineViewController: exploreItem = .list(listVC.list) - case let hashtagVC as HashtagTimelineViewController where hashtagVC.isHashtagSaved: - exploreItem = .savedHashtag(hashtagVC.hashtag) + case let hashtagVC as HashtagTimelineViewController where sidebar.hasItem(.savedHashtag(hashtagVC.hashtagName)): + exploreItem = .savedHashtag(hashtagVC.hashtagName) case let instanceVC as InstanceTimelineViewController: exploreItem = .savedInstance(instanceVC.instanceURL) case is TrendsViewController: @@ -428,8 +428,8 @@ fileprivate extension MainSidebarViewController.Item { return FavoritesViewController(mastodonController: mastodonController) case let .list(list): return ListTimelineViewController(for: list, mastodonController: mastodonController) - case let .savedHashtag(hashtag): - return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController) + case let .savedHashtag(name): + return HashtagTimelineViewController(forNamed: name, mastodonController: mastodonController) case let .savedInstance(url): return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController) case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance: diff --git a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift index e0b294d1..a1d5c764 100644 --- a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift +++ b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift @@ -11,23 +11,30 @@ import Pachyderm class HashtagTimelineViewController: TimelineViewController { - let hashtag: Hashtag + let hashtagName: String + private var hashtag: Hashtag? + private var fetchHashtag: Task? var toggleSaveButton: UIBarButtonItem! var isHashtagSaved: Bool { - let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!) + let req = SavedHashtag.fetchRequest(name: hashtagName, account: mastodonController.accountInfo!) return mastodonController.persistentContainer.viewContext.objectExists(for: req) } private var isHashtagFollowed: Bool { - mastodonController.followedHashtags.contains(where: { $0.name == hashtag.name }) + mastodonController.followedHashtags.contains(where: { $0.name == hashtagName }) } - init(for hashtag: Hashtag, mastodonController: MastodonController) { + convenience init(for hashtag: Hashtag, mastodonController: MastodonController) { + self.init(forNamed: hashtag.name, mastodonController: mastodonController) self.hashtag = hashtag + } + + init(forNamed name: String, mastodonController: MastodonController) { + self.hashtagName = name - super.init(for: .tag(hashtag: hashtag.name), mastodonController: mastodonController) + super.init(for: .tag(hashtag: hashtagName), mastodonController: mastodonController) } required init?(coder aDecoder: NSCoder) { @@ -36,11 +43,38 @@ class HashtagTimelineViewController: TimelineViewController { override func viewDidLoad() { super.viewDidLoad() + + if hashtag == nil { + fetchHashtag = Task { + let name = hashtagName.lowercased() + let hashtag: Hashtag? + if mastodonController.instanceFeatures.hasMastodonVersion(4, 0, 0) { + let req = Client.getHashtag(name: name) + hashtag = try? await mastodonController.run(req).0 + } else { + let req = Client.search(query: "#\(name)", types: [.hashtags]) + let results = try? await mastodonController.run(req).0 + hashtag = results?.hashtags.first(where: { $0.name.lowercased() == name }) + } + self.hashtag = hashtag + return hashtag + } + } 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!))) + if let hashtag = self.hashtag { + elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!))) + } else { + Task { + if let hashtag = await fetchHashtag?.value { + elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!))) + } else { + elementHandler([]) + } + } + } }) ]) navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu) @@ -48,17 +82,28 @@ class HashtagTimelineViewController: TimelineViewController { private func toggleSave() { let context = mastodonController.persistentContainer.viewContext - if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first { + if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtagName, account: mastodonController.accountInfo!)).first { context.delete(existing) + mastodonController.persistentContainer.save(context: context) } else { - _ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context) + Task { @MainActor in + let hashtag: Hashtag? + if let tag = self.hashtag { + hashtag = tag + } else { + hashtag = await fetchHashtag?.value + } + if let hashtag { + _ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context) + mastodonController.persistentContainer.save(context: context) + } + } } - mastodonController.persistentContainer.save(context: context) } private func toggleFollow() { Task { - await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow() + await ToggleFollowHashtagService(hashtagName: hashtagName, presenter: self).toggleFollow() } } diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 68d3f186..ae9d9fa3 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -151,7 +151,7 @@ extension MenuActionProvider { 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() + await ToggleFollowHashtagService(hashtagName: hashtag.name, presenter: navigationDelegate!).toggleFollow() } }) }