diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 70b40ee2..63f98ec9 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -289,6 +289,8 @@ D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; }; D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; }; D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */; }; + D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; }; + D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; }; D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; }; D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; @@ -641,6 +643,8 @@ D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = ""; }; D6E57FA425C26FAB00341037 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagCollectionViewCell.swift; sourceTree = ""; }; + D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = ""; }; + D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = ""; }; D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; }; D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; @@ -794,6 +798,8 @@ D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */, D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */, D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */, + D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */, + D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */, D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */, D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */, D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */, @@ -1624,6 +1630,7 @@ buildActionMask = 2147483647; files = ( D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */, + D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */, D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */, @@ -1912,6 +1919,7 @@ D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, + D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */, D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */, D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */, D6B81F442560390300F6E31D /* MenuController.swift in Sources */, diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index ddc60e00..aec9107c 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -48,7 +48,7 @@ class MastodonController: ObservableObject { @Published private(set) var instanceFeatures = InstanceFeatures() private(set) var customEmojis: [Emoji]? - private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]() + private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() private var ownInstanceRequest: URLSessionTask? var loggedIn: Bool { @@ -159,15 +159,28 @@ class MastodonController: ObservableObject { } func getOwnInstance(completion: ((Instance) -> Void)? = nil) { - getOwnInstanceInternal(retryAttempt: 0, completion: completion) + getOwnInstanceInternal(retryAttempt: 0) { + if case let .success(instance) = $0 { + completion?(instance) + } + } } - private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) { + @MainActor + func getOwnInstance() async throws -> Instance { + return try await withCheckedThrowingContinuation({ continuation in + getOwnInstanceInternal(retryAttempt: 0) { result in + continuation.resume(with: result) + } + }) + } + + private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks assert(Thread.isMainThread) if let instance = self.instance { - completion?(instance) + completion?(.success(instance)) } else { if let completion = completion { pendingOwnInstanceRequestCallbacks.append(completion) @@ -177,7 +190,7 @@ class MastodonController: ObservableObject { let request = Client.getInstance() ownInstanceRequest = run(request) { (response) in switch response { - case .failure(_): + case .failure(let error): let delay: DispatchTimeInterval switch retryAttempt { case 0: @@ -190,6 +203,10 @@ class MastodonController: ObservableObject { delay = .seconds(60) default: // if we've failed four times, just give up :/ + for completion in self.pendingOwnInstanceRequestCallbacks { + completion(.failure(error)) + } + self.pendingOwnInstanceRequestCallbacks = [] return } DispatchQueue.main.asyncAfter(deadline: .now() + delay) { @@ -204,7 +221,7 @@ class MastodonController: ObservableObject { self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo) for completion in self.pendingOwnInstanceRequestCallbacks { - completion(instance) + completion(.success(instance)) } self.pendingOwnInstanceRequestCallbacks = [] } diff --git a/Tusker/Controllers/MenuController.swift b/Tusker/Controllers/MenuController.swift index 46143304..f96f6a8f 100644 --- a/Tusker/Controllers/MenuController.swift +++ b/Tusker/Controllers/MenuController.swift @@ -22,7 +22,7 @@ struct MenuController { let data: Any if case let .tab(tab) = item { data = tab.rawValue - } else if case .search = item { + } else if case .explore = item { data = "search" } else if case .bookmarks = item { data = "bookmarks" @@ -42,7 +42,7 @@ struct MenuController { static let sidebarItemKeyCommands: [UIKeyCommand] = [ sidebarCommand(item: .tab(.timelines), command: "1"), sidebarCommand(item: .tab(.notifications), command: "2"), - sidebarCommand(item: .search, command: "3"), + sidebarCommand(item: .explore, command: "3"), sidebarCommand(item: .bookmarks, command: "4"), sidebarCommand(item: .tab(.myProfile), command: "5"), ] diff --git a/Tusker/InstanceFeatures.swift b/Tusker/InstanceFeatures.swift index 44f2e275..d3773cfa 100644 --- a/Tusker/InstanceFeatures.swift +++ b/Tusker/InstanceFeatures.swift @@ -34,6 +34,10 @@ struct InstanceFeatures { instanceType != .pixelfed } + var trends: Bool { + instanceType == .mastodon + } + var trendingStatusesAndLinks: Bool { instanceType == .mastodon && version != nil && version! >= Version(3, 5, 0) } diff --git a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift new file mode 100644 index 00000000..a3d81596 --- /dev/null +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift @@ -0,0 +1,141 @@ +// +// TrendingLinkCardCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 6/29/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class TrendingLinkCardCollectionViewCell: UICollectionViewCell { + + private var card: Card? + private var isGrayscale = false + private var thumbnailRequest: ImageCache.Request? + + @IBOutlet weak var thumbnailView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var providerLabel: UILabel! + @IBOutlet weak var activityLabel: UILabel! + @IBOutlet weak var historyView: TrendHistoryView! + + override func awakeFromNib() { + super.awakeFromNib() + + layer.shadowOpacity = 0.2 + layer.shadowRadius = 8 + layer.shadowOffset = .zero + layer.masksToBounds = false + updateLayerColors() + + NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layer.cornerRadius = 0.05 * bounds.width + thumbnailView.layer.cornerRadius = 0.05 * bounds.width + } + + func updateUI(card: Card) { + self.card = card + self.thumbnailView.image = nil + + updateGrayscaleableUI(card: card) + updateUIForPreferences() + + let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) + titleLabel.text = title + + let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines) + providerLabel.text = provider + + let sorted = card.history!.sorted(by: { $0.day < $1.day }) + let lastTwo = sorted[(sorted.count - 2)...] + let accounts = lastTwo.map(\.accounts).reduce(0, +) + let uses = lastTwo.map(\.uses).reduce(0, +) + + // U+2009 THIN SPACE + let activityStr = NSMutableAttributedString(string: "\(accounts.formatted())\u{2009}") + activityStr.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "person")!))) + activityStr.append(NSAttributedString(string: ", \(uses.formatted())\u{2009}")) + activityStr.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "square.text.square")!))) + activityLabel.attributedText = activityStr + + historyView.setHistory(card.history) + historyView.isHidden = card.history == nil || card.history!.count < 2 + } + + @objc private func updateUIForPreferences() { + if isGrayscale != Preferences.shared.grayscaleImages, + let card { + updateGrayscaleableUI(card: card) + } + } + + private func updateGrayscaleableUI(card: Card) { + isGrayscale = Preferences.shared.grayscaleImages + + if let imageURL = card.image, + let url = URL(imageURL) { + thumbnailRequest = ImageCache.attachments.get(url, completion: { _, image in + guard let image, + let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else { + return + } + DispatchQueue.main.async { + self.thumbnailView.image = transformedImage + } + }) + if thumbnailRequest != nil { + loadBlurHash(card: card) + } + } + } + + private func loadBlurHash(card: Card) { + guard let hash = card.blurhash else { + return + } + let imageViewSize = self.thumbnailView.bounds.size + AttachmentView.queue.async { [weak self] in + let size: CGSize + if let width = card.width, let height = card.height { + size = CGSize(width: width, height: height) + } else { + size = imageViewSize + } + + guard let preview = UIImage(blurHash: hash, size: size) else { + return + } + DispatchQueue.main.async { [weak self] in + guard let self, + self.card?.url == card.url, + self.thumbnailView.image == nil else { + return + } + self.thumbnailView.image = preview + } + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateLayerColors() + } + + private func updateLayerColors() { + if traitCollection.userInterfaceStyle == .dark { +// clippingView.layer.borderColor = UIColor.darkGray.withAlphaComponent(0.5).cgColor + layer.shadowColor = UIColor.darkGray.cgColor + } else { +// clippingView.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor + layer.shadowColor = UIColor.black.cgColor + } + } + +} diff --git a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib new file mode 100644 index 00000000..6ce990d8 --- /dev/null +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/Screens/Explore/TrendingLinkTableViewCell.swift b/Tusker/Screens/Explore/TrendingLinkTableViewCell.swift index e3f39b6f..a7017c23 100644 --- a/Tusker/Screens/Explore/TrendingLinkTableViewCell.swift +++ b/Tusker/Screens/Explore/TrendingLinkTableViewCell.swift @@ -113,6 +113,10 @@ class TrendingLinkTableViewCell: UITableViewCell { } @objc private func updateUIForPreferences() { + if isGrayscale != Preferences.shared.grayscaleImages, + let card { + updateGrayscaleableUI(card: card) + } } private func updateGrayscaleableUI(card: Card) { diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index c95e8c66..817e1edc 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -37,7 +37,7 @@ class MainSidebarViewController: UIViewController { } var exploreTabItems: [Item] { - var items: [Item] = [.search, .bookmarks, .trendingStatuses, .trendingTags, .trendingLinks, .profileDirectory] + var items: [Item] = [.explore, .bookmarks, .trendingStatuses, .profileDirectory] let snapshot = dataSource.snapshot() for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) { items.append(.list(list)) @@ -154,7 +154,7 @@ class MainSidebarViewController: UIViewController { snapshot.appendItems([ .tab(.timelines), .tab(.notifications), - .search, + .explore, .bookmarks, .tab(.myProfile) ], toSection: .tabs) @@ -177,12 +177,10 @@ class MainSidebarViewController: UIViewController { var discoverSnapshot = NSDiffableDataSourceSectionSnapshot() discoverSnapshot.append([.discoverHeader]) discoverSnapshot.append([ - .trendingTags, .profileDirectory, ], to: .discoverHeader) if mastodonController.instanceFeatures.trendingStatusesAndLinks { - discoverSnapshot.insert([.trendingStatuses], before: .trendingTags) - discoverSnapshot.insert([.trendingLinks], after: .trendingTags) + discoverSnapshot.insert([.trendingStatuses], before: .profileDirectory) } dataSource.apply(discoverSnapshot, to: .discover) } @@ -345,7 +343,7 @@ class MainSidebarViewController: UIViewController { return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode) case .tab(.compose): return UserActivityManager.newPostActivity(accountID: id) - case .search: + case .explore: return UserActivityManager.searchActivity() case .bookmarks: return UserActivityManager.bookmarksActivity() @@ -384,8 +382,8 @@ extension MainSidebarViewController { } enum Item: Hashable { case tab(MainTabBarViewController.Tab) - case search, bookmarks - case discoverHeader, trendingStatuses, trendingTags, trendingLinks, profileDirectory + case explore, bookmarks + case discoverHeader, trendingStatuses, profileDirectory case listsHeader, list(List), addList case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag case savedInstancesHeader, savedInstance(URL), addSavedInstance @@ -394,18 +392,14 @@ extension MainSidebarViewController { switch self { case let .tab(tab): return tab.title - case .search: - return "Search" + case .explore: + return "Explore" case .bookmarks: return "Bookmarks" case .discoverHeader: return "Discover" case .trendingStatuses: return "Trending Posts" - case .trendingTags: - return "Trending Hashtags" - case .trendingLinks: - return "Trending Links" case .profileDirectory: return "Profile Directory" case .listsHeader: @@ -433,16 +427,12 @@ extension MainSidebarViewController { switch self { case let .tab(tab): return tab.imageName - case .search: + case .explore: return "magnifyingglass" case .bookmarks: return "bookmark" case .trendingStatuses: - return "doc.text.image" - case .trendingTags: - return "number" - case .trendingLinks: - return "link" + return "square.text.square" case .profileDirectory: return "person.2.fill" case .list(_): diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index cf6fa51b..18f90fa9 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -100,7 +100,7 @@ class MainSplitViewController: UISplitViewController { item = .tab(MainTabBarViewController.Tab(rawValue: index)!) } else if let str = command.propertyList as? String { if str == "search" { - item = .search + item = .explore } else if str == "bookmarks" { item = .bookmarks } else { @@ -171,7 +171,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate { $0.1 > $1.1 } if let mostRecentExploreItem = mostRecentExploreItem?.0, - mostRecentExploreItem != .search { + mostRecentExploreItem != .explore { let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController // Pop back to root, so we're appending to the Explore VC instead of some other VC exploreNav.popToRootViewController(animated: false) @@ -188,7 +188,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate { // sidebar items that map 1 <-> 1 can be transferred directly tabBarViewController.select(tab: tab) - case .search: + case .explore: // Search sidebar item maps to the Explore tab with the search controller/results visible // The nav stack can't be copied directly, since the split VC uses a different SearchViewController // so that explore items aren't shown multiple times. @@ -217,11 +217,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate { explore.resultsController.loadResults(from: search.resultsController) // Transfer the navigation stack, dropping the search VC, to keep anything the user has opened - transferNavigationStack(from: .search, to: exploreNav, dropFirst: true, append: true) + transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true) tabBarViewController.select(tab: .explore) - case .bookmarks, .trendingStatuses, .trendingTags, .trendingLinks, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_): + case .bookmarks, .trendingStatuses, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_): tabBarViewController.select(tab: .explore) // Make sure the Explore VC doesn't show it's search bar when it appears, in case the user was previously // in compact mode and performing a search. @@ -272,7 +272,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate { // For other items, the 2nd VC in the nav stack determines which sidebar item they map to. // Search screen has special considerations, all others can be transferred directly. if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) { - exploreItem = .search + exploreItem = .explore let searchVC = SearchViewController(mastodonController: mastodonController) searchVC.loadViewIfNeeded() let explore = tabNavigationStack.first as! ExploreViewController @@ -300,9 +300,9 @@ extension MainSplitViewController: UISplitViewControllerDelegate { case is TrendingStatusesViewController: exploreItem = .trendingStatuses case is TrendingHashtagsViewController: - exploreItem = .trendingTags + exploreItem = .explore case is TrendingLinksViewController: - exploreItem = .trendingLinks + exploreItem = .explore case is ProfileDirectoryViewController: exploreItem = .profileDirectory default: @@ -354,16 +354,12 @@ fileprivate extension MainSidebarViewController.Item { switch self { case let .tab(tab): return tab.createViewController(mastodonController) - case .search: + case .explore: return SearchViewController(mastodonController: mastodonController) case .bookmarks: return BookmarksTableViewController(mastodonController: mastodonController) case .trendingStatuses: return TrendingStatusesViewController(mastodonController: mastodonController) - case .trendingTags: - return TrendingHashtagsViewController(mastodonController: mastodonController) - case .trendingLinks: - return TrendingLinksViewController(mastodonController: mastodonController) case .profileDirectory: return ProfileDirectoryViewController(mastodonController: mastodonController) case let .list(list): @@ -435,8 +431,8 @@ extension MainSplitViewController: TuskerRootViewController { return } - if sidebar.selectedItem != .search { - select(item: .search) + if sidebar.selectedItem != .explore { + select(item: .explore) } guard let searchViewController = secondaryNavController.viewControllers.first as? SearchViewController else { diff --git a/Tusker/Screens/Search/SearchViewController.swift b/Tusker/Screens/Search/SearchViewController.swift index 1b7aab91..1abc9300 100644 --- a/Tusker/Screens/Search/SearchViewController.swift +++ b/Tusker/Screens/Search/SearchViewController.swift @@ -7,11 +7,16 @@ // import UIKit +import Pachyderm +import SafariServices class SearchViewController: UIViewController { weak var mastodonController: MastodonController! + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + var resultsController: SearchResultsViewController! var searchController: UISearchController! @@ -22,7 +27,7 @@ class SearchViewController: UIViewController { super.init(nibName: nil, bundle: nil) - title = NSLocalizedString("Search", comment: "search tab title") + title = NSLocalizedString("Explore", comment: "explore tab title") } required init?(coder: NSCoder) { @@ -32,12 +37,46 @@ class SearchViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground + let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in + let sectionIdentifier = self.dataSource.snapshot().sectionIdentifiers[sectionIndex] + switch sectionIdentifier { + case .trendingHashtags: + var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped) + listConfig.headerMode = .supplementary + return .list(using: listConfig, layoutEnvironment: environment) + + case .trendingLinks: + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + // todo: i really wish i could just say the height is automatic and let autolayout figure out what it needs to be + // using .estimated(whatever) constrains the height to exactly whatever + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil) + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary + section.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) + ] + return section + + default: + fatalError("unimplemented") + } + } + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.delegate = self + collectionView.dragDelegate = self + collectionView.backgroundColor = .secondarySystemBackground + view.addSubview(collectionView) + + dataSource = createDataSource() resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController.exploreNavigationController = self.navigationController searchController = UISearchController(searchResultsController: resultsController) - searchController.obscuresBackgroundDuringPresentation = false + searchController.obscuresBackgroundDuringPresentation = true searchController.searchBar.autocapitalizationType = .none searchController.searchBar.delegate = resultsController searchController.hidesNavigationBarDuringPresentation = false @@ -48,6 +87,18 @@ class SearchViewController: UIViewController { if #available(iOS 16.0, *) { navigationItem.preferredSearchBarPlacement = .stacked } + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Task(priority: .userInitiated) { + if (try? await mastodonController.getOwnInstance()) != nil { + await applySnapshot() + } + } } override func viewDidAppear(_ animated: Bool) { @@ -61,5 +112,214 @@ class SearchViewController: UIViewController { searchControllerStatusOnAppearance = nil } } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let sectionHeaderCell = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in + let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] + var config = UIListContentConfiguration.groupedHeader() + config.text = section.title + headerView.contentConfiguration = config + } + + let trendingHashtagCell = UICollectionView.CellRegistration { (cell, indexPath, hashtag) in + cell.updateUI(hashtag: hashtag) + } + let trendingLinkCell = UICollectionView.CellRegistration(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in + cell.updateUI(card: card) + } + + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case let .tag(hashtag): + return collectionView.dequeueConfiguredReusableCell(using: trendingHashtagCell, for: indexPath, item: hashtag) + + case let .link(card): + return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card) + + default: + fatalError("todo") + } + } + dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in + if elementKind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath) + } else { + return nil + } + } + return dataSource + } + + @MainActor + private func applySnapshot() async { + guard mastodonController.instanceFeatures.trends, + !Preferences.shared.hideDiscover else { + await dataSource.apply(NSDiffableDataSourceSnapshot()) + return + } + + var snapshot = NSDiffableDataSourceSnapshot() + + let hashtagsReq = Client.getTrendingHashtags(limit: 5) + async let hashtags = try? mastodonController.run(hashtagsReq).0 + let linksReq = Client.getTrendingLinks(limit: 10) + async let links = try? mastodonController.run(linksReq).0 + + if let hashtags = await hashtags { + snapshot.appendSections([.trendingHashtags]) + snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags) + } + + if let links = await links { + snapshot.appendSections([.trendingLinks]) + snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks) + } + + await dataSource.apply(snapshot) + } + + @objc private func preferencesChanged() { + Task { + await applySnapshot() + } + } } + +extension SearchViewController { + enum Section { + case trendingHashtags + case trendingLinks + case trendingStatuses + case profileSuggestions + + var title: String { + switch self { + case .trendingHashtags: + return "Trending Hashtags" + case .trendingLinks: + return "Trending Links" + case .trendingStatuses: + return "Trending Statuses" + case .profileSuggestions: + return "Suggested Accounts" + } + } + } + enum Item: Equatable, Hashable { + case status(String) + case tag(Hashtag) + case link(Card) + + static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool { + switch (lhs, rhs) { + case let (.status(a), .status(b)): + return a == b + case let (.tag(a), .tag(b)): + return a == b + case let (.link(a), .link(b)): + return a.url == b.url + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case let .status(id): + hasher.combine("status") + hasher.combine(id) + case let .tag(tag): + hasher.combine("tag") + hasher.combine(tag.name) + case let .link(card): + hasher.combine("link") + hasher.combine(card.url) + } + } + } +} + +extension SearchViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return + } + switch item { + case let .tag(hashtag): + show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) + + case let .link(card): + if let url = URL(card.url) { + selected(url: url) + } + + default: + fatalError("todo") + } + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case let .tag(hashtag): + return UIContextMenuConfiguration(identifier: nil) { + HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) + } actionProvider: { (_) in + UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath))) + } + + case let .link(card): + guard let url = URL(card.url) else { + return nil + } + return UIContextMenuConfiguration { + SFSafariViewController(url: url) + } actionProvider: { _ in + UIMenu(children: self.actionsForTrendingLink(card: card)) + } + + default: + fatalError("todo") + } + } +} + +extension SearchViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return [] + } + switch item { + case let .tag(hashtag): + let provider = NSItemProvider(object: hashtag.url as NSURL) + if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + } + return [UIDragItem(itemProvider: provider)] + + case let .link(card): + guard let url = URL(card.url) else { + return [] + } + return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] + + default: + fatalError("todo") + } + } +} + +extension SearchViewController: TuskerNavigationDelegate { + var apiController: MastodonController { mastodonController } +} + +extension SearchViewController: ToastableViewController { +} + +extension SearchViewController: MenuActionProvider { +}