diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index 1c5f6cc7..44ffd35c 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -323,7 +323,7 @@ public class Client { return request } - // MARK: - Trends + // MARK: - Instance public static func getTrends(limit: Int? = nil) -> Request<[Hashtag]> { let parameters: [Parameter] if let limit = limit { @@ -334,6 +334,20 @@ public class Client { return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters) } + public static func getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> { + var parameters = [ + "order" => order.rawValue, + "local" => local, + ] + if let offset = offset { + parameters.append("offset" => offset) + } + if let limit = limit { + parameters.append("limit" => limit) + } + return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters) + } + } extension Client { diff --git a/Pachyderm/Model/DirectoryOrder.swift b/Pachyderm/Model/DirectoryOrder.swift new file mode 100644 index 00000000..25a6fdb7 --- /dev/null +++ b/Pachyderm/Model/DirectoryOrder.swift @@ -0,0 +1,14 @@ +// +// DirectoryOrder.swift +// Pachyderm +// +// Created by Shadowfacts on 2/6/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import Foundation + +public enum DirectoryOrder: String { + case active + case new +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index e4fee34e..ee99ea32 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; + D600613E25D07E170067FAD6 /* ProfileDirectoryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; }; D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FAE25BE0B01004811E6 /* TrendingHashtagTableViewCell.swift */; }; D6093FB125BE0B01004811E6 /* TrendingHashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6093FAF25BE0B01004811E6 /* TrendingHashtagTableViewCell.xib */; }; @@ -197,6 +198,10 @@ D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; }; D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; }; + D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; }; + D693A72C25CF8D15003A14E2 /* DirectoryOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72B25CF8D15003A14E2 /* DirectoryOrder.swift */; }; + D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; }; + D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */; }; D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; }; D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; }; @@ -381,6 +386,7 @@ 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; + D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryFilterView.swift; sourceTree = ""; }; D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = ""; }; D6093FAE25BE0B01004811E6 /* TrendingHashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagTableViewCell.swift; sourceTree = ""; }; D6093FAF25BE0B01004811E6 /* TrendingHashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingHashtagTableViewCell.xib; sourceTree = ""; }; @@ -564,6 +570,10 @@ D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = ""; }; D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = ""; }; + D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = ""; }; + D693A72B25CF8D15003A14E2 /* DirectoryOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryOrder.swift; sourceTree = ""; }; + D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = ""; }; + D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = ""; }; D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = ""; }; D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = ""; }; D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = ""; }; @@ -818,6 +828,7 @@ D61099E6214561FF00432DC2 /* Attachment.swift */, D61099E82145658300432DC2 /* Card.swift */, D61099EA2145661700432DC2 /* ConversationContext.swift */, + D693A72B25CF8D15003A14E2 /* DirectoryOrder.swift */, D61099E22144C38900432DC2 /* Emoji.swift */, D61099EC2145664800432DC2 /* Filter.swift */, D6109A0021456B0800432DC2 /* Hashtag.swift */, @@ -917,6 +928,10 @@ D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */, D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */, D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */, + D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */, + D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */, + D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */, + D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */, ); path = Explore; sourceTree = ""; @@ -1740,6 +1755,7 @@ buildActionMask = 2147483647; files = ( D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */, + D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */, D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */, @@ -1834,6 +1850,7 @@ D61099D02144B2D700432DC2 /* Method.swift in Sources */, D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */, D61099FB214569F600432DC2 /* Report.swift in Sources */, + D693A72C25CF8D15003A14E2 /* DirectoryOrder.swift in Sources */, D61099F92145698900432DC2 /* Relationship.swift in Sources */, D61099E12144C1DC00432DC2 /* Account.swift in Sources */, D61099E92145658300432DC2 /* Card.swift in Sources */, @@ -1876,6 +1893,7 @@ D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */, D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */, D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */, + D600613E25D07E170067FAD6 /* ProfileDirectoryFilterView.swift in Sources */, D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, @@ -1969,7 +1987,9 @@ D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, + D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */, + D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */, D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 9014b4c0..1ff72b92 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -134,7 +134,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(Section.allCases) snapshot.appendItems([.bookmarks], toSection: .bookmarks) - snapshot.appendItems([.trendingTags], toSection: .discover) + snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover) snapshot.appendItems([.addList], toSection: .lists) snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) @@ -259,6 +259,9 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { case .trendingTags: show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil) + case .profileDirectory: + show(ProfileDirectoryViewController(mastodonController: mastodonController), sender: nil) + case let .list(list): show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil) @@ -336,6 +339,7 @@ extension ExploreViewController { enum Item: Hashable { case bookmarks case trendingTags + case profileDirectory case list(List) case addList case savedHashtag(Hashtag) @@ -349,6 +353,8 @@ extension ExploreViewController { return NSLocalizedString("Bookmarks", comment: "bookmarks nav item title") case .trendingTags: return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title") + case .profileDirectory: + return NSLocalizedString("Profile Directory", comment: "profile directory nav item title") case let .list(list): return list.title case .addList: @@ -371,6 +377,8 @@ extension ExploreViewController { name = "bookmark.fill" case .trendingTags: name = "arrow.up.arrow.down" + case .profileDirectory: + name = "person.2.fill" case .list(_): name = "list.bullet" case .addList, .addSavedHashtag: @@ -391,6 +399,8 @@ extension ExploreViewController { return true case (.trendingTags, .trendingTags): return true + case (.profileDirectory, .profileDirectory): + return true case let (.list(a), .list(b)): return a.id == b.id case (.addList, .addList): @@ -414,6 +424,8 @@ extension ExploreViewController { hasher.combine("bookmarks") case .trendingTags: hasher.combine("trendingTags") + case .profileDirectory: + hasher.combine("profileDirectory") case let .list(list): hasher.combine("list") hasher.combine(list.id) @@ -468,7 +480,7 @@ extension ExploreViewController: UICollectionViewDragDelegate { case let .savedInstance(url): provider = NSItemProvider(object: url as NSURL) // todo: should dragging public timelines into new windows be supported? - case .trendingTags, .addList, .addSavedHashtag, .findInstance: + case .trendingTags, .profileDirectory, .addList, .addSavedHashtag, .findInstance: return [] } return [UIDragItem(itemProvider: provider)] diff --git a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift new file mode 100644 index 00000000..8afcf9c2 --- /dev/null +++ b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift @@ -0,0 +1,87 @@ +// +// FeaturedProfileCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 2/6/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class FeaturedProfileCollectionViewCell: UICollectionViewCell { + + @IBOutlet weak var headerImageView: UIImageView! + @IBOutlet weak var avatarContainerView: UIView! + @IBOutlet weak var avatarImageView: UIImageView! + @IBOutlet weak var displayNameLabel: EmojiLabel! + @IBOutlet weak var noteTextView: StatusContentTextView! + + var account: Account? + + private var avatarRequest: ImageCache.Request? + private var headerRequest: ImageCache.Request? + + override func awakeFromNib() { + super.awakeFromNib() + + avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) + + noteTextView.defaultFont = .systemFont(ofSize: 16) + noteTextView.textContainer.lineBreakMode = .byTruncatingTail + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + } + + func updateUI(account: Account) { + self.account = account + + displayNameLabel.updateForAccountDisplayName(account: account) + + noteTextView.setTextFromHtml(account.note) + noteTextView.setEmojis(account.emojis) + + avatarImageView.image = nil + avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (_, image) in + defer { + self?.avatarRequest = nil + } + guard let self = self, + let image = image, + self.account?.id == account.id else { + return + } + DispatchQueue.main.async { + self.avatarImageView.image = image + } + } + + headerImageView.image = nil + if let header = account.header { + headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in + defer { + self?.headerRequest = nil + } + guard let self = self, + let image = image, + self.account?.id == account.id else { + return + } + DispatchQueue.main.async { + self.headerImageView.image = image + } + } + } + } + + @objc private func preferencesChanged() { + avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) + + if let account = account { + displayNameLabel.updateForAccountDisplayName(account: account) + } + } + +} diff --git a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.xib b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.xib new file mode 100644 index 00000000..1d302d2f --- /dev/null +++ b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.xib @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/Screens/Explore/ProfileDirectoryFilterView.swift b/Tusker/Screens/Explore/ProfileDirectoryFilterView.swift new file mode 100644 index 00000000..ad681fad --- /dev/null +++ b/Tusker/Screens/Explore/ProfileDirectoryFilterView.swift @@ -0,0 +1,132 @@ +// +// ProfileDirectoryFilterView.swift +// Tusker +// +// Created by Shadowfacts on 2/7/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class ProfileDirectoryFilterView: UICollectionReusableView { + + var onFilterChanged: ((Scope, DirectoryOrder) -> Void)? + + private var scope: UISegmentedControl! + private var sort: UISegmentedControl! + + override init(frame: CGRect) { + super.init(frame: frame) + + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + commonInit() + } + + private func commonInit() { + scope = UISegmentedControl(items: ["Instance", NSLocalizedString("Everywhere", comment: "everywhere profile directory scope")]) + scope.selectedSegmentIndex = 0 + scope.addTarget(self, action: #selector(filterChanged), for: .valueChanged) + + sort = UISegmentedControl(items: [ + NSLocalizedString("Active", comment: "active profile directory sort"), + NSLocalizedString("New", comment: "new profile directory sort"), + ]) + sort.selectedSegmentIndex = 0 + sort.addTarget(self, action: #selector(filterChanged), for: .valueChanged) + + let fromLabel = UILabel() + fromLabel.translatesAutoresizingMaskIntoConstraints = false + fromLabel.text = NSLocalizedString("From", comment: "profile directory scope label") + fromLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let sortLabel = UILabel() + sortLabel.translatesAutoresizingMaskIntoConstraints = false + sortLabel.text = NSLocalizedString("Sort By", comment: "profile directory sort label") + sortLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let labelContainer = UIView() + labelContainer.addSubview(sortLabel) + labelContainer.addSubview(fromLabel) + + let controlStack = UIStackView(arrangedSubviews: [sort, scope]) + controlStack.axis = .vertical + controlStack.spacing = 8 + + let blurEffect = UIBlurEffect(style: .systemChromeMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurView) + + let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .label) + let vibrancyView = UIVisualEffectView(effect: vibrancyEffect) + vibrancyView.translatesAutoresizingMaskIntoConstraints = false + blurView.contentView.addSubview(vibrancyView) + + let filterStack = UIStackView(arrangedSubviews: [ + labelContainer, + controlStack, + ]) + filterStack.axis = .horizontal + filterStack.spacing = 8 + filterStack.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.contentView.addSubview(filterStack) + + let separator = UIView() + separator.backgroundColor = .separator + separator.translatesAutoresizingMaskIntoConstraints = false + addSubview(separator) + + NSLayoutConstraint.activate([ + fromLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor), + fromLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor), + fromLabel.centerYAnchor.constraint(equalTo: scope.centerYAnchor), + + sortLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor), + sortLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor), + sortLabel.centerYAnchor.constraint(equalTo: sort.centerYAnchor), + + blurView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurView.topAnchor.constraint(equalTo: topAnchor), + blurView.bottomAnchor.constraint(equalTo: bottomAnchor), + + vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor), + vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor), + vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor), + vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor), + + filterStack.leadingAnchor.constraint(equalToSystemSpacingAfter: vibrancyView.contentView.leadingAnchor, multiplier: 1), + vibrancyView.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: filterStack.trailingAnchor, multiplier: 1), + filterStack.topAnchor.constraint(equalToSystemSpacingBelow: vibrancyView.contentView.topAnchor, multiplier: 1), + vibrancyView.contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: filterStack.bottomAnchor, multiplier: 1), + + separator.leadingAnchor.constraint(equalTo: leadingAnchor), + separator.trailingAnchor.constraint(equalTo: trailingAnchor), + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.heightAnchor.constraint(equalToConstant: 0.5), + ]) + } + + func updateUI(mastodonController: MastodonController) { + scope.setTitle(mastodonController.accountInfo!.instanceURL.host!, forSegmentAt: 0) + } + + @objc private func filterChanged() { + let scope = Scope(rawValue: scope.selectedSegmentIndex)! + let order = sort.selectedSegmentIndex == 0 ? DirectoryOrder.active : .new + onFilterChanged?(scope, order) + } + +} + +extension ProfileDirectoryFilterView { + enum Scope: Int, Equatable { + case instance, everywhere + } +} diff --git a/Tusker/Screens/Explore/ProfileDirectoryViewController.swift b/Tusker/Screens/Explore/ProfileDirectoryViewController.swift new file mode 100644 index 00000000..5d9f648f --- /dev/null +++ b/Tusker/Screens/Explore/ProfileDirectoryViewController.swift @@ -0,0 +1,191 @@ +// +// ProfileDirectoryViewController.swift +// Tusker +// +// Created by Shadowfacts on 2/6/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class ProfileDirectoryViewController: UIViewController { + + weak var mastodonController: MastodonController! + + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + + init(mastodonController: MastodonController) { + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = NSLocalizedString("Profile Directory", comment: "profile directory title") + + let configuration = UICollectionViewCompositionalLayoutConfiguration() + configuration.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100)), + elementKind: "filter", + alignment: .top + ) + ] + let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in + let itemHeight = NSCollectionLayoutDimension.absolute(200) + let itemWidth: NSCollectionLayoutDimension + if case .compact = layoutEnvironment.traitCollection.horizontalSizeClass { + itemWidth = .fractionalWidth(1) + } else { + itemWidth = .absolute((layoutEnvironment.container.contentSize.width - 12) / 2) + } + + let itemSize = NSCollectionLayoutSize(widthDimension: itemWidth, heightDimension: itemHeight) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let itemB = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: itemHeight) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, itemB]) + group.interItemSpacing = .flexible(4) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 4 + if case .compact = layoutEnvironment.traitCollection.horizontalSizeClass { + section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0) + } else { + section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4) + } + return section + }, configuration: configuration) + + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = .secondarySystemBackground + collectionView.register(UINib(nibName: "FeaturedProfileCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: "featuredProfileCell") + collectionView.register(ProfileDirectoryFilterView.self, forSupplementaryViewOfKind: "filter", withReuseIdentifier: "filter") + collectionView.delegate = self + collectionView.dragDelegate = self + view.addSubview(collectionView) + + dataSource = createDataSource() + updateProfiles(local: true, order: .active) + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in + guard case let .account(account) = item else { fatalError() } + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "featuredProfileCell", for: indexPath) as! FeaturedProfileCollectionViewCell + cell.updateUI(account: account) + return cell + } + dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in + guard elementKind == "filter" else { + return nil + } + let filterView = collectionView.dequeueReusableSupplementaryView(ofKind: "filter", withReuseIdentifier: "filter", for: indexPath) as! ProfileDirectoryFilterView + filterView.updateUI(mastodonController: self.mastodonController) + filterView.onFilterChanged = { [weak self] (scope, order) in + guard let self = self else { return } + self.dataSource.apply(.init()) + self.updateProfiles(local: scope == .instance, order: order) + } + return filterView + } + return dataSource + } + + private func updateProfiles(local: Bool, order: DirectoryOrder) { + let request = Client.getFeaturedProfiles(local: local, order: order) + mastodonController.run(request) { (response) in + guard case let .success(accounts, _) = response else { + return + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.featuredProfiles]) + snapshot.appendItems(accounts.map { .account($0) }) + DispatchQueue.main.async { + self.dataSource.apply(snapshot) + } + } + } + +} + +extension ProfileDirectoryViewController { + enum Section { + case featuredProfiles + } + enum Item: Hashable { + case account(Account) + + func hash(into hasher: inout Hasher) { + guard case let .account(account) = self else { return } + hasher.combine(account.id) + } + } +} + +extension ProfileDirectoryViewController: TuskerNavigationDelegate { + var apiController: MastodonController { mastodonController } +} + +extension ProfileDirectoryViewController: MenuPreviewProvider { + var navigationDelegate: TuskerNavigationDelegate? { self } +} + +extension ProfileDirectoryViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath), + case let .account(account) = item else { + return + } + show(ProfileViewController(accountID: account.id, mastodonController: mastodonController), sender: nil) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath), + case let .account(account) = item else { + return nil + } + + return UIContextMenuConfiguration(identifier: nil) { + return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController) + } actionProvider: { (_) in + let actions = self.actionsForProfile(accountID: account.id, sourceView: self.collectionView.cellForItem(at: indexPath)) + return UIMenu(children: actions) + } + + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + if let viewController = animator.previewViewController { + animator.preferredCommitStyle = .pop + animator.addCompletion { + self.show(viewController, sender: nil) + } + } + } +} + +extension ProfileDirectoryViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let item = dataSource.itemIdentifier(for: indexPath), + case let .account(account) = item, + let currentAccountID = mastodonController.accountInfo?.id else { + return [] + } + let provider = NSItemProvider(object: account.url as NSURL) + let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) + provider.registerObject(activity, visibility: .all) + return [UIDragItem(itemProvider: provider)] + } +} diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index 71830354..32ab59a0 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -32,7 +32,7 @@ class MainSidebarViewController: UIViewController { } var exploreTabItems: [Item] { - var items: [Item] = [.search, .bookmarks, .trendingTags] + var items: [Item] = [.search, .bookmarks, .trendingTags, .profileDirectory] let snapshot = dataSource.snapshot() for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) { items.append(.list(list)) @@ -143,6 +143,7 @@ class MainSidebarViewController: UIViewController { ], toSection: .compose) snapshot.appendItems([ .trendingTags, + .profileDirectory, ], toSection: .discover) dataSource.apply(snapshot, animatingDifferences: false) @@ -283,7 +284,7 @@ extension MainSidebarViewController { enum Item: Hashable { case tab(MainTabBarViewController.Tab) case search, bookmarks - case trendingTags + case trendingTags, profileDirectory case listsHeader, list(List), addList case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag case savedInstancesHeader, savedInstance(URL), addSavedInstance @@ -298,6 +299,8 @@ extension MainSidebarViewController { return "Bookmarks" case .trendingTags: return "Trending Hashtags" + case .profileDirectory: + return "Profile Directory" case .listsHeader: return "Lists" case let .list(list): @@ -329,6 +332,8 @@ extension MainSidebarViewController { return "bookmark" case .trendingTags: return "arrow.up.arrow.down" + case .profileDirectory: + return "person.2.fill" case .list(_): return "list.bullet" case .savedHashtag(_): diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 90a1825f..a04d74f6 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -203,7 +203,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate { tabBarViewController.select(tab: .explore) - case .bookmarks, .trendingTags, .list(_), .savedHashtag(_), .savedInstance(_): + case .bookmarks, .trendingTags, .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. @@ -279,6 +279,8 @@ extension MainSplitViewController: UISplitViewControllerDelegate { exploreItem = .savedInstance(instanceVC.instanceURL) } else if tabNavigationStack[1] is TrendingHashtagsViewController { exploreItem = .trendingTags + } else if tabNavigationStack[1] is ProfileDirectoryViewController { + exploreItem = .profileDirectory } transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend) @@ -332,6 +334,8 @@ fileprivate extension MainSidebarViewController.Item { return BookmarksTableViewController(mastodonController: mastodonController) case .trendingTags: return TrendingHashtagsViewController(mastodonController: mastodonController) + case .profileDirectory: + return ProfileDirectoryViewController(mastodonController: mastodonController) case let .list(list): return ListTimelineViewController(for: list, mastodonController: mastodonController) case let .savedHashtag(hashtag):