From d2c76640739a8951046263669073940b5a23d02a Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 23 Jan 2023 17:10:26 -0500 Subject: [PATCH] Add profile suggestions to Explore on iPad --- .../Pachyderm/Sources/Pachyderm/Client.swift | 6 + .../Sources/Pachyderm/Model/Suggestion.swift | 25 ++ Tusker.xcodeproj/project.pbxproj | 8 + Tusker/API/InstanceFeatures.swift | 4 + ...ggestedProfileCardCollectionViewCell.swift | 216 ++++++++++++++++++ ...SuggestedProfileCardCollectionViewCell.xib | 128 +++++++++++ .../TrendingLinkCardCollectionViewCell.swift | 7 +- .../TrendingLinkCardCollectionViewCell.xib | 4 +- .../Screens/Search/SearchViewController.swift | 107 ++++++++- 9 files changed, 487 insertions(+), 18 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Suggestion.swift create mode 100644 Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift create mode 100644 Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.xib diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 65ba2958..eb16d6a5 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -438,6 +438,12 @@ public class Client { return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters) } + public static func getSuggestions(limit: Int?) -> Request<[Suggestion]> { + return Request(method: .get, path: "/api/v2/suggestions", queryParameters: [ + "limit" => limit, + ]) + } + } extension Client { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Suggestion.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Suggestion.swift new file mode 100644 index 00000000..074b2139 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Suggestion.swift @@ -0,0 +1,25 @@ +// +// Suggestion.swift +// Pachyderm +// +// Created by Shadowfacts on 1/22/23. +// + +import Foundation + +public struct Suggestion: Decodable { + public let source: Source + public let account: Account + + public static func remove(accountID: String) -> Request { + return Request(method: .delete, path: "/api/v1/suggestions/\(accountID)") + } +} + +extension Suggestion { + public enum Source: String, Decodable { + case staff + case pastInteractions = "past_interactions" + case global + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7ef5d37f..b4870af8 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; }; D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; }; D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */; }; + D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */; }; + D601FA84297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; @@ -417,6 +419,8 @@ D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = ""; }; D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = ""; }; D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusCollectionViewCell.swift; sourceTree = ""; }; + D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfileCardCollectionViewCell.swift; sourceTree = ""; }; + D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SuggestedProfileCardCollectionViewCell.xib; sourceTree = ""; }; D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = ""; }; D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = ""; }; D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = ""; }; @@ -895,6 +899,8 @@ D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */, D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */, D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */, + D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */, + D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */, D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */, D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */, D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */, @@ -1806,6 +1812,7 @@ D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */, D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */, D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */, + D601FA84297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib in Resources */, D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */, D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */, D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */, @@ -1994,6 +2001,7 @@ D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, + D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, diff --git a/Tusker/API/InstanceFeatures.swift b/Tusker/API/InstanceFeatures.swift index 907c52c8..bafc5730 100644 --- a/Tusker/API/InstanceFeatures.swift +++ b/Tusker/API/InstanceFeatures.swift @@ -51,6 +51,10 @@ struct InstanceFeatures { instanceType.isMastodon } + var profileSuggestions: Bool { + instanceType.isMastodon && hasMastodonVersion(3, 4, 0) + } + var trendingStatusesAndLinks: Bool { instanceType.isMastodon && hasMastodonVersion(3, 5, 0) } diff --git a/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift new file mode 100644 index 00000000..9a0688ac --- /dev/null +++ b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift @@ -0,0 +1,216 @@ +// +// SuggestedProfileCardCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 1/23/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import SwiftUI + +class SuggestedProfileCardCollectionViewCell: UICollectionViewCell { + + weak var delegate: (any TuskerNavigationDelegate)? + private var mastodonController: MastodonController! { delegate?.apiController } + + private var accountID: String! + private var source: Suggestion.Source! + + @IBOutlet weak var headerImageView: CachedImageView! + @IBOutlet weak var avatarContainerView: UIView! + @IBOutlet weak var avatarImageView: CachedImageView! + @IBOutlet weak var displayNameLabel: EmojiLabel! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var noteTextView: StatusContentTextView! + @IBOutlet weak var suggestionSourceButton: UIButton! + + private var hoverGestureAnimator: UIViewPropertyAnimator? + + override func awakeFromNib() { + super.awakeFromNib() + + layer.shadowOpacity = 0.2 + layer.shadowRadius = 8 + layer.shadowOffset = .zero + layer.masksToBounds = false + contentView.layer.cornerRadius = 12.5 + updateLayerColors() + + headerImageView.cache = .headers + + avatarContainerView.layer.masksToBounds = true + avatarImageView.cache = .avatars + avatarImageView.layer.masksToBounds = true + + displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold)) + displayNameLabel.adjustsFontForContentSizeCategory = true + + usernameLabel.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 15, weight: .light)) + usernameLabel.adjustsFontForContentSizeCategory = true + + noteTextView.defaultFont = .preferredFont(forTextStyle: .body) + noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) + noteTextView.adjustsFontForContentSizeCategory = true + noteTextView.textContainer.lineBreakMode = .byTruncatingTail + + addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized))) + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + } + + @objc private func preferencesChanged() { + guard let accountID, + let mastodonController, + let account = mastodonController.persistentContainer.account(for: accountID) else { + return + } + updateUIForPreferences(account: account) + } + + func updateUI(accountID: String, source: Suggestion.Source) { + guard self.accountID != accountID, + let account = mastodonController.persistentContainer.account(for: accountID) else { + return + } + self.accountID = accountID + self.source = source + + updateUIForPreferences(account: account) + + avatarImageView.update(for: account.avatar) + headerImageView.update(for: account.header) + usernameLabel.text = "@\(account.acct)" + noteTextView.setTextFromHtml(account.note) + + var config = UIButton.Configuration.plain() + config.image = source.image + suggestionSourceButton.configuration = config + suggestionSourceButton.setNeedsUpdateConfiguration() + } + + private func updateUIForPreferences(account: AccountMO) { + avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) + displayNameLabel.updateForAccountDisplayName(account: account) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateLayerColors() + } + + private func updateLayerColors() { + if traitCollection.userInterfaceStyle == .dark { + layer.shadowColor = UIColor.darkGray.cgColor + } else { + layer.shadowColor = UIColor.black.cgColor + } + } + + // MARK: Interaction + + @IBAction func suggestionSourceButtonPressed(_ sender: Any) { + guard let delegate, + let source else { + return + } + let view = SuggestionSourceView(mastodonController: mastodonController, source: source) + let host = UIHostingController(rootView: view) + let toPresent: UIViewController + if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact { + toPresent = UINavigationController(rootViewController: host) + toPresent.modalPresentationStyle = .pageSheet + let sheetPresentationController = toPresent.sheetPresentationController! + sheetPresentationController.detents = [ + .medium() + ] + } else { + host.modalPresentationStyle = .popover + let popoverPresentationController = host.popoverPresentationController! + popoverPresentationController.sourceView = suggestionSourceButton + host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity)) + toPresent = host + } + delegate.present(toPresent, animated: true) + } + + @objc private func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) { + switch recognizer.state { + case .began, .changed: + hoverGestureAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut, animations: { + self.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) + }) + hoverGestureAnimator!.startAnimation() + case .ended: + hoverGestureAnimator?.stopAnimation(true) + hoverGestureAnimator?.addAnimations { + self.transform = .identity + } + hoverGestureAnimator?.startAnimation() + default: + break + } + } + +} + +private extension Suggestion.Source { + var image: UIImage { + switch self { + case .global: + return UIImage(systemName: "chart.line.uptrend.xyaxis")! + case .pastInteractions: + return UIImage(systemName: "clock.arrow.circlepath")! + case .staff: + return UIImage(systemName: "person.2")! + } + } + + var title: String { + switch self { + case .global: + return "Popular Recently" + case .pastInteractions: + return "Past Interactions" + case .staff: + return "Staff Recommendation" + } + } +} + +private struct SuggestionSourceView: View { + let mastodonController: MastodonController + let source: Suggestion.Source + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading) { + HStack { + Image(uiImage: source.image) + Text(source.title) + Spacer() + } + switch source { + case .global: + Text("This account is suggested for you because it has been highly active within the past 30 days.") + case .pastInteractions: + Text("This account is suggested for you because you have interacted with it before.") + case .staff: + Text("This account is recommended by the staff of \(mastodonController.accountInfo!.instanceURL.host!)") + } + } + .padding() + .navigationTitle("Suggestion Reason") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } +} diff --git a/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.xib b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.xib new file mode 100644 index 00000000..5cfa00c3 --- /dev/null +++ b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.xib @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/TrendingLinkCardCollectionViewCell.swift b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift index 976485f9..13e11f22 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift @@ -33,17 +33,12 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell { layer.shadowRadius = 8 layer.shadowOffset = .zero layer.masksToBounds = false + contentView.layer.cornerRadius = 12.5 updateLayerColors() addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized))) } - override func layoutSubviews() { - super.layoutSubviews() - - contentView.layer.cornerRadius = 0.05 * bounds.width - } - func updateUI(card: Card) { self.card = card self.thumbnailView.image = nil diff --git a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib index 6480291d..23391e6d 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.xib @@ -56,9 +56,9 @@ - + - +