Compare commits

...

2 Commits

7 changed files with 89 additions and 31 deletions

View File

@ -483,6 +483,12 @@ public class Client {
]) ])
} }
// MARK: - Hashtags
/// Requires Mastodon 4.0.0+
public static func getHashtag(name: String) -> Request<Hashtag> {
return Request(method: .get, path: "/api/v1/tags/\(name)")
}
} }
extension Client { extension Client {

View File

@ -12,12 +12,12 @@ import Pachyderm
@MainActor @MainActor
class ToggleFollowHashtagService { class ToggleFollowHashtagService {
private let hashtag: Hashtag private let hashtagName: String
private let mastodonController: MastodonController private let mastodonController: MastodonController
private let presenter: any TuskerNavigationDelegate private let presenter: any TuskerNavigationDelegate
init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) { init(hashtagName: String, presenter: any TuskerNavigationDelegate) {
self.hashtag = hashtag self.hashtagName = hashtagName
self.mastodonController = presenter.apiController self.mastodonController = presenter.apiController
self.presenter = presenter self.presenter = presenter
} }
@ -25,9 +25,9 @@ class ToggleFollowHashtagService {
func toggleFollow() async { func toggleFollow() async {
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
var config: ToastConfiguration 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 { do {
let req = Hashtag.unfollow(name: hashtag.name) let req = Hashtag.unfollow(name: hashtagName)
_ = try await mastodonController.run(req) _ = try await mastodonController.run(req)
context.delete(existing) context.delete(existing)
@ -44,7 +44,7 @@ class ToggleFollowHashtagService {
} }
} else { } else {
do { do {
let req = Hashtag.follow(name: hashtag.name) let req = Hashtag.follow(name: hashtagName)
let (hashtag, _) = try await mastodonController.run(req) let (hashtag, _) = try await mastodonController.run(req)
_ = FollowedHashtag(hashtag: hashtag, context: context) _ = FollowedHashtag(hashtag: hashtag, context: context)

View File

@ -106,7 +106,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
private func timelineViewController(for timeline: Timeline, mastodonController: MastodonController) -> UIViewController { private func timelineViewController(for timeline: Timeline, mastodonController: MastodonController) -> UIViewController {
switch timeline { 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): case .list(id: let id):
let req = ListMO.fetchRequest(id: id) let req = ListMO.fetchRequest(id: id)
if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first { if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first {

View File

@ -127,6 +127,11 @@ class MainSidebarViewController: UIViewController {
itemLastSelectedTimestamps[item] = Date() itemLastSelectedTimestamps[item] = Date()
} }
func hasItem(_ item: Item) -> Bool {
loadViewIfNeeded()
return dataSource.snapshot().itemIdentifiers.contains(item)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
var config = cell.defaultContentConfiguration() var config = cell.defaultContentConfiguration()
@ -212,10 +217,10 @@ class MainSidebarViewController: UIViewController {
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
var items = saved.map { 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 }) { 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 = items.uniques()
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
@ -315,8 +320,8 @@ class MainSidebarViewController: UIViewController {
return UserActivityManager.myProfileActivity(accountID: id) return UserActivityManager.myProfileActivity(accountID: id)
case let .list(list): case let .list(list):
return UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: id) return UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: id)
case let .savedHashtag(tag): case let .savedHashtag(name):
return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: id) return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: name), accountID: id)
case .savedInstance(_): case .savedInstance(_):
// todo: show timeline activity doesn't work for public timelines // todo: show timeline activity doesn't work for public timelines
return nil return nil
@ -347,7 +352,7 @@ extension MainSidebarViewController {
case tab(MainTabBarViewController.Tab) case tab(MainTabBarViewController.Tab)
case explore, bookmarks, favorites case explore, bookmarks, favorites
case listsHeader, list(List), addList case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag case savedHashtagsHeader, savedHashtag(String), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance case savedInstancesHeader, savedInstance(URL), addSavedInstance
var title: String { var title: String {
@ -368,8 +373,8 @@ extension MainSidebarViewController {
return "New List..." return "New List..."
case .savedHashtagsHeader: case .savedHashtagsHeader:
return "Hashtags" return "Hashtags"
case let .savedHashtag(hashtag): case let .savedHashtag(name):
return hashtag.name return name
case .addSavedHashtag: case .addSavedHashtag:
return "Add Hashtag..." return "Add Hashtag..."
case .savedInstancesHeader: case .savedInstancesHeader:

View File

@ -339,8 +339,8 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
exploreItem = .favorites exploreItem = .favorites
case let listVC as ListTimelineViewController: case let listVC as ListTimelineViewController:
exploreItem = .list(listVC.list) exploreItem = .list(listVC.list)
case let hashtagVC as HashtagTimelineViewController where hashtagVC.isHashtagSaved: case let hashtagVC as HashtagTimelineViewController where sidebar.hasItem(.savedHashtag(hashtagVC.hashtagName)):
exploreItem = .savedHashtag(hashtagVC.hashtag) exploreItem = .savedHashtag(hashtagVC.hashtagName)
case let instanceVC as InstanceTimelineViewController: case let instanceVC as InstanceTimelineViewController:
exploreItem = .savedInstance(instanceVC.instanceURL) exploreItem = .savedInstance(instanceVC.instanceURL)
case is TrendsViewController: case is TrendsViewController:
@ -428,8 +428,8 @@ fileprivate extension MainSidebarViewController.Item {
return FavoritesViewController(mastodonController: mastodonController) return FavoritesViewController(mastodonController: mastodonController)
case let .list(list): case let .list(list):
return ListTimelineViewController(for: list, mastodonController: mastodonController) return ListTimelineViewController(for: list, mastodonController: mastodonController)
case let .savedHashtag(hashtag): case let .savedHashtag(name):
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController) return HashtagTimelineViewController(forNamed: name, mastodonController: mastodonController)
case let .savedInstance(url): case let .savedInstance(url):
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController) return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance: case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:

View File

@ -11,23 +11,30 @@ import Pachyderm
class HashtagTimelineViewController: TimelineViewController { class HashtagTimelineViewController: TimelineViewController {
let hashtag: Hashtag let hashtagName: String
private var hashtag: Hashtag?
private var fetchHashtag: Task<Hashtag?, Never>?
var toggleSaveButton: UIBarButtonItem! var toggleSaveButton: UIBarButtonItem!
var isHashtagSaved: Bool { 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) return mastodonController.persistentContainer.viewContext.objectExists(for: req)
} }
private var isHashtagFollowed: Bool { 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 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) { required init?(coder aDecoder: NSCoder) {
@ -36,11 +43,38 @@ class HashtagTimelineViewController: TimelineViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.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: [ let menu = UIMenu(children: [
// uncached so that the saved/followed updates every time // uncached so that the saved/followed updates every time
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in 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) navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
@ -48,17 +82,28 @@ class HashtagTimelineViewController: TimelineViewController {
private func toggleSave() { private func toggleSave() {
let context = mastodonController.persistentContainer.viewContext 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) context.delete(existing)
mastodonController.persistentContainer.save(context: context)
} else { } 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() { private func toggleFollow() {
Task { Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow() await ToggleFollowHashtagService(hashtagName: hashtagName, presenter: self).toggleFollow()
} }
} }

View File

@ -133,7 +133,7 @@ extension MenuActionProvider {
let name = hashtag.name.lowercased() let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker" let saveSubtitle = "Shown in the Explore section of Tusker"
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus") let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
actionsSection = [ actionsSection = [
UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
@ -147,11 +147,11 @@ extension MenuActionProvider {
] ]
if mastodonController.instanceFeatures.canFollowHashtags { if mastodonController.instanceFeatures.canFollowHashtags {
let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name }) let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
let subtitle = "Posts tagged with followed hashtags appear in your Home timeline" let subtitle = "Posts appear in your Home timeline"
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus") 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 actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
Task { Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow() await ToggleFollowHashtagService(hashtagName: hashtag.name, presenter: navigationDelegate!).toggleFollow()
} }
}) })
} }