diff --git a/Tusker/Screens/Main/NewMainTabBarViewController.swift b/Tusker/Screens/Main/NewMainTabBarViewController.swift index c0a7f02f..da54db08 100644 --- a/Tusker/Screens/Main/NewMainTabBarViewController.swift +++ b/Tusker/Screens/Main/NewMainTabBarViewController.swift @@ -24,6 +24,7 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { private var favoritesTab: UITab! private var myProfileTab: UITab! private var listsGroup: UITabGroup! + private var hashtagsGroup: UITabGroup! private var cancellables = Set() @@ -67,6 +68,17 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { ] reloadLists(mastodonController.lists) + hashtagsGroup = UITabGroup(title: "Hashtags", image: nil, identifier: Tab.hashtags.rawValue, children: []) { _ in + return AdaptableNavigationController() + } + hashtagsGroup.preferredPlacement = .sidebarOnly + hashtagsGroup.sidebarActions = [ + UIAction(title: "Add Hashtag…", image: UIImage(systemName: "plus"), handler: { [unowned self] _ in + self.showAddSavedHashtag() + }) + ] + reloadHashtags() + if UIDevice.current.userInterfaceIdiom == .phone { self.tabs = [ homeTab, @@ -83,13 +95,19 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { let vcToUpdate = self.selectedTab!.parent?.viewController ?? self.selectedTab!.viewController! self.updateViewControllerSafeAreaInsets(vcToUpdate) } + + mastodonController.$lists + .sink { [unowned self] in self.reloadLists($0) } + .store(in: &cancellables) + + mastodonController.$followedHashtags + .map { _ in () } + .merge(with: NotificationCenter.default.publisher(for: .savedHashtagsChanged).map { _ in () }) + .sink { [unowned self] in self.reloadHashtags() } + .store(in: &cancellables) } setupFastAccountSwitcher() - - mastodonController.$lists - .sink { [unowned self] in self.reloadLists($0) } - .store(in: &cancellables) } private func updatePadTabs() { @@ -99,8 +117,9 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { isCompact = true var exploreNavStack: [UIViewController]? = nil - if selectedTab?.parent == listsGroup { - let nav = listsGroup.viewController as! any NavigationControllerProtocol + if let parent = selectedTab?.parent, + parent === listsGroup || parent === hashtagsGroup { + let nav = parent.viewController as! any NavigationControllerProtocol exploreNavStack = nav.viewControllers nav.viewControllers = [] } @@ -121,24 +140,33 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { } else { isCompact = false - var newTab: (UITab, [UIViewController])? = nil + var newTabAndNavigationStack: (UITab, [UIViewController])? = nil if wasCompact == true, selectedTab == exploreTab { let nav = exploreTab.viewController as! any NavigationControllerProtocol // skip over the ExploreViewController if nav.viewControllers.count > 1 { + var newTab: UITab? switch nav.viewControllers[1] { case let listVC as ListTimelineViewController: if let tab = listsGroup.tab(forIdentifier: ListTab.identifier(for: listVC.list)) { - newTab = (tab, Array(nav.viewControllers[1...])) - nav.viewControllers = [ - nav.viewControllers[0], // leave the ExploreVC in place - InlineTrendsViewController(mastodonController: mastodonController), // re-insert an InlineTrendsVC - ] + newTab = tab + } + case let hashtagVC as HashtagTimelineViewController: + if let tab = hashtagsGroup.tab(forIdentifier: HashtagTab.identifier(for: hashtagVC.hashtagName)) { + newTab = tab } default: break } + + if let newTab { + newTabAndNavigationStack = (newTab, Array(nav.viewControllers[1...])) + nav.viewControllers = [ + nav.viewControllers[0], // leave the ExploreVC in place + InlineTrendsViewController(mastodonController: mastodonController), // re-insert an InlineTrendsVC + ] + } } } @@ -151,9 +179,10 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { myProfileTab, composeTab, listsGroup, + hashtagsGroup, ] - if let (tab, navStack) = newTab { + if let (tab, navStack) = newTabAndNavigationStack { let nav = tab.parent!.viewController as! any NavigationControllerProtocol nav.viewControllers = navStack // Setting the tab now seems to be clobbered by the UITabBarController itself updating in response @@ -193,7 +222,7 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { root = FavoritesViewController(mastodonController: mastodonController) case .myProfile: root = MyProfileViewController(mastodonController: mastodonController) - case .lists: + case .lists, .hashtags: fatalError("unreachable") } return embedInNavigationController(root) @@ -217,11 +246,37 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { } private func reloadLists(_ lists: [List]) { - listsGroup.children = lists.map { list in - ListTab(list: list) { [unowned self] _ in - return ListTimelineViewController(for: list, mastodonController: self.mastodonController) - } + let viewControllerProvider = { [unowned self] (tab: UITab) in + let tab = tab as! ListTab + return ListTimelineViewController(for: tab.list, mastodonController: self.mastodonController) } + listsGroup.children = lists.map { list in + ListTab(list: list, viewControllerProvider: viewControllerProvider) + } + } + + private func reloadHashtags() { + let viewControllerProvider = { [unowned self] (tab: UITab) in + let tab = tab as! HashtagTab + return HashtagTimelineViewController(forNamed: tab.hashtagName, mastodonController: self.mastodonController) + } + var seenTags: Set = [] + var tabs: [UITab] = [] + let savedReq = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) + let saved = (try? mastodonController.persistentContainer.viewContext.fetch(savedReq)) ?? [] + for hashtag in saved { + seenTags.insert(hashtag.name) + tabs.append(HashtagTab(hashtagName: hashtag.name, viewControllerProvider: viewControllerProvider)) + } + + let followedReq = FollowedHashtag.fetchRequest() + let followed = (try? mastodonController.persistentContainer.viewContext.fetch(followedReq)) ?? [] + for hashtag in followed where !seenTags.contains(hashtag.name) { + tabs.append(HashtagTab(hashtagName: hashtag.name, viewControllerProvider: viewControllerProvider)) + } + + tabs.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) + hashtagsGroup.children = tabs } @objc func handleComposeKeyCommand() { @@ -244,6 +299,12 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { service.run() } + private func showAddSavedHashtag() { + let addController = AddSavedHashtagViewController(mastodonController: mastodonController) + let nav = EnhancedNavigationViewController(rootViewController: addController) + present(nav, animated: true) + } + fileprivate func updateViewControllerSafeAreaInsets(_ vc: UIViewController) { guard vc is MultiColumnNavigationController || (vc as? AdaptableNavigationController)?.current is MultiColumnNavigationController else { return @@ -317,6 +378,7 @@ extension NewMainTabBarViewController { case myProfile case lists + case hashtags } } @@ -356,7 +418,7 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate { // get the new transition animation. // This would be much less complicated if the controller just used the individual VCs of items in a group. if let group = newTab.parent, - group.identifier == Tab.lists.rawValue, + group === listsGroup || group === hashtagsGroup, let nav = group.viewController as? any NavigationControllerProtocol { updateViewControllerSafeAreaInsets(nav) @@ -430,6 +492,13 @@ extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate { } else { return nil } + } else if let hashtagTab = tab as? HashtagTab { + let timelineActivity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtagTab.hashtagName), accountID: id) + if let timelineActivity { + activity = timelineActivity + } else { + return nil + } } else if let tabID = Tab(rawValue: tab.identifier) { switch tabID { case .home: @@ -448,7 +517,7 @@ extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate { return nil case .compose: activity = UserActivityManager.newPostActivity(accountID: id) - case .lists: + case .lists, .hashtags: return nil } } else { @@ -671,3 +740,17 @@ private class ListTab: UITab { "list:\(list.id)" } } + +@available(iOS 18.0, *) +private class HashtagTab: UITab { + let hashtagName: String + + init(hashtagName: String, viewControllerProvider: @escaping (UITab) -> UIViewController) { + self.hashtagName = hashtagName + super.init(title: hashtagName, image: UIImage(systemName: "number"), identifier: Self.identifier(for: hashtagName), viewControllerProvider: viewControllerProvider) + } + + static func identifier(for name: String) -> String { + "hashtag:\(name)" + } +}