Add profile suggestions to Explore on iPad
This commit is contained in:
parent
e91249a876
commit
d2c7664073
|
@ -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 {
|
||||
|
|
|
@ -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<Empty> {
|
||||
return Request(method: .delete, path: "/api/v1/suggestions/\(accountID)")
|
||||
}
|
||||
}
|
||||
|
||||
extension Suggestion {
|
||||
public enum Source: String, Decodable {
|
||||
case staff
|
||||
case pastInteractions = "past_interactions"
|
||||
case global
|
||||
}
|
||||
}
|
|
@ -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 = "<group>"; };
|
||||
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfileCardCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SuggestedProfileCardCollectionViewCell.xib; sourceTree = "<group>"; };
|
||||
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
|
||||
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
|
||||
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="collection view cell content view" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="Z5E-Hf-n4L" customClass="SuggestedProfileCardCollectionViewCell" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="447" height="325"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Jcb-fI-gAO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="447" height="325"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="7fZ-qb-OUH" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="447" height="100"/>
|
||||
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="100" id="OYE-GJ-2eQ"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mYt-NG-Qs0">
|
||||
<rect key="frame" x="8" y="55" width="90" height="90"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="6Ev-Aa-3Mc" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="2" y="2" width="86" height="86"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="86" id="dpV-uh-zbU"/>
|
||||
<constraint firstAttribute="width" constant="86" id="lrh-qG-ETr"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="6Ev-Aa-3Mc" firstAttribute="centerY" secondItem="mYt-NG-Qs0" secondAttribute="centerY" id="IPQ-Ku-dNq"/>
|
||||
<constraint firstItem="6Ev-Aa-3Mc" firstAttribute="centerX" secondItem="mYt-NG-Qs0" secondAttribute="centerX" id="PJS-9F-5hw"/>
|
||||
<constraint firstAttribute="height" constant="90" id="lvP-pY-zQX"/>
|
||||
<constraint firstAttribute="width" constant="90" id="wav-YT-e4Y"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="XCk-sZ-ujT" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="106" y="100" width="333" height="29"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bVM-lv-OK8">
|
||||
<rect key="frame" x="106" y="129" width="333" height="18"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="8" y="147" width="431" height="170"/>
|
||||
<string key="text">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.</string>
|
||||
<color key="textColor" systemColor="labelColor"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="fOC-AS-NkL" customClass="ProfileHeaderButton" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="9.9999999999999964" y="7.9999999999999964" width="51.666666666666657" height="51.666666666666657"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" secondItem="fOC-AS-NkL" secondAttribute="height" multiplier="1:1" id="ab5-nM-00d"/>
|
||||
</constraints>
|
||||
<state key="normal" title="Button"/>
|
||||
<buttonConfiguration key="configuration" style="plain" image="clock.arrow.circlepath" catalog="system"/>
|
||||
<connections>
|
||||
<action selector="suggestionSourceButtonPressed:" destination="Z5E-Hf-n4L" eventType="touchUpInside" id="gXT-zC-Kvl"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="FJh-fd-fo8" firstAttribute="top" secondItem="bVM-lv-OK8" secondAttribute="bottom" id="1Os-JD-p6O"/>
|
||||
<constraint firstAttribute="trailing" secondItem="XCk-sZ-ujT" secondAttribute="trailing" constant="8" id="FPN-6T-KIm"/>
|
||||
<constraint firstItem="7fZ-qb-OUH" firstAttribute="leading" secondItem="Jcb-fI-gAO" secondAttribute="leading" id="Nq6-3c-fs0"/>
|
||||
<constraint firstItem="fOC-AS-NkL" firstAttribute="leading" secondItem="6Ev-Aa-3Mc" secondAttribute="leading" id="Tr9-Il-Q4H"/>
|
||||
<constraint firstItem="bVM-lv-OK8" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="6Ev-Aa-3Mc" secondAttribute="bottom" id="Uad-gZ-GhS"/>
|
||||
<constraint firstItem="mYt-NG-Qs0" firstAttribute="leading" secondItem="Jcb-fI-gAO" secondAttribute="leading" constant="8" id="Wco-Rg-NEM"/>
|
||||
<constraint firstItem="fOC-AS-NkL" firstAttribute="top" secondItem="Jcb-fI-gAO" secondAttribute="top" constant="8" id="aqE-ad-qD8"/>
|
||||
<constraint firstAttribute="trailing" secondItem="7fZ-qb-OUH" secondAttribute="trailing" id="bBk-xa-K6q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="bVM-lv-OK8" secondAttribute="trailing" constant="8" id="c7e-ha-JfL"/>
|
||||
<constraint firstItem="mYt-NG-Qs0" firstAttribute="centerY" secondItem="7fZ-qb-OUH" secondAttribute="bottom" id="cDW-a5-aLv"/>
|
||||
<constraint firstItem="7fZ-qb-OUH" firstAttribute="top" secondItem="Jcb-fI-gAO" secondAttribute="top" id="g1U-Ig-HW6"/>
|
||||
<constraint firstItem="FJh-fd-fo8" firstAttribute="leading" secondItem="Jcb-fI-gAO" secondAttribute="leading" constant="8" id="kE2-It-xgV"/>
|
||||
<constraint firstAttribute="trailing" secondItem="FJh-fd-fo8" secondAttribute="trailing" constant="8" id="ljC-j2-dB1"/>
|
||||
<constraint firstAttribute="bottom" secondItem="FJh-fd-fo8" secondAttribute="bottom" constant="8" id="nMc-OE-cEj"/>
|
||||
<constraint firstItem="bVM-lv-OK8" firstAttribute="top" secondItem="XCk-sZ-ujT" secondAttribute="bottom" id="neo-Eg-Y1D"/>
|
||||
<constraint firstItem="XCk-sZ-ujT" firstAttribute="top" secondItem="7fZ-qb-OUH" secondAttribute="bottom" id="nsb-mR-pJV"/>
|
||||
<constraint firstItem="bVM-lv-OK8" firstAttribute="leading" secondItem="mYt-NG-Qs0" secondAttribute="trailing" constant="8" id="tb5-R8-1wh"/>
|
||||
<constraint firstItem="XCk-sZ-ujT" firstAttribute="leading" secondItem="mYt-NG-Qs0" secondAttribute="trailing" constant="8" id="uQt-9z-Sci"/>
|
||||
</constraints>
|
||||
</collectionViewCellContentView>
|
||||
<size key="customSize" width="447" height="325"/>
|
||||
<connections>
|
||||
<outlet property="avatarContainerView" destination="mYt-NG-Qs0" id="7pR-Ml-smY"/>
|
||||
<outlet property="avatarImageView" destination="6Ev-Aa-3Mc" id="lec-fD-8F5"/>
|
||||
<outlet property="displayNameLabel" destination="XCk-sZ-ujT" id="UgE-Rm-sNi"/>
|
||||
<outlet property="headerImageView" destination="7fZ-qb-OUH" id="59z-43-WWQ"/>
|
||||
<outlet property="noteTextView" destination="FJh-fd-fo8" id="ciO-6u-r4d"/>
|
||||
<outlet property="suggestionSourceButton" destination="fOC-AS-NkL" id="8A0-RB-lLU"/>
|
||||
<outlet property="usernameLabel" destination="bVM-lv-OK8" id="hpN-xe-5vq"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="-45.038167938931295" y="123.59154929577466"/>
|
||||
</collectionViewCell>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="clock.arrow.circlepath" catalog="system" width="128" height="112"/>
|
||||
<systemColor name="labelColor">
|
||||
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="secondaryLabelColor">
|
||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="secondarySystemBackgroundColor">
|
||||
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
|
@ -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
|
||||
|
|
|
@ -56,9 +56,9 @@
|
|||
</constraints>
|
||||
</view>
|
||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cWo-9n-z42">
|
||||
<rect key="frame" x="0.0" y="196.66666666666666" width="300" height="28.333333333333343"/>
|
||||
<rect key="frame" x="0.0" y="196.33333333333334" width="300" height="28.666666666666657"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="ktv-3s-cp9">
|
||||
<rect key="frame" x="0.0" y="0.0" width="300" height="28"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="300" height="28.666666666666657"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" showsExpansionTextWhenTruncated="YES" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ho3-cU-IGi">
|
||||
|
|
|
@ -62,13 +62,24 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
|
||||
return section
|
||||
|
||||
case .profileSuggestions:
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(250))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(250))
|
||||
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)
|
||||
]
|
||||
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
|
||||
return section
|
||||
|
||||
case .trendingStatuses:
|
||||
var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
listConfig.headerMode = .supplementary
|
||||
return NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
|
||||
|
||||
default:
|
||||
fatalError("unimplemented")
|
||||
return .list(using: listConfig, layoutEnvironment: environment)
|
||||
}
|
||||
}
|
||||
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||
|
@ -147,6 +158,10 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
// TODO: filter trends
|
||||
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
|
||||
}
|
||||
let accountCell = UICollectionView.CellRegistration<SuggestedProfileCardCollectionViewCell, (String, Suggestion.Source)>(cellNib: UINib(nibName: "SuggestedProfileCardCollectionViewCell", bundle: .main)) { [unowned self] cell, indexPath, item in
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: item.0, source: item.1)
|
||||
}
|
||||
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
|
||||
switch item {
|
||||
|
@ -158,6 +173,9 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
|
||||
case let .status(id, state):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||
|
||||
case let .account(id, source):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: (id, source))
|
||||
}
|
||||
}
|
||||
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
|
||||
|
@ -181,13 +199,23 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
|
||||
let hashtagsReq = Client.getTrendingHashtags(limit: 5)
|
||||
async let hashtags = try? mastodonController.run(hashtagsReq).0
|
||||
let hashtags = try? await mastodonController.run(hashtagsReq).0
|
||||
|
||||
if let hashtags = await hashtags {
|
||||
if let hashtags {
|
||||
snapshot.appendSections([.trendingHashtags])
|
||||
snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags)
|
||||
}
|
||||
|
||||
if mastodonController.instanceFeatures.profileSuggestions {
|
||||
let req = Client.getSuggestions(limit: 10)
|
||||
let suggestions = try? await mastodonController.run(req).0
|
||||
if let suggestions {
|
||||
snapshot.appendSections([.profileSuggestions])
|
||||
await mastodonController.persistentContainer.addAll(accounts: suggestions.map(\.account))
|
||||
snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) }, toSection: .profileSuggestions)
|
||||
}
|
||||
}
|
||||
|
||||
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
|
||||
let linksReq = Client.getTrendingLinks(limit: 10)
|
||||
async let links = try? mastodonController.run(linksReq).0
|
||||
|
@ -195,7 +223,11 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
async let statuses = try? mastodonController.run(statusesReq).0
|
||||
|
||||
if let links = await links {
|
||||
snapshot.appendSections([.trendingLinks])
|
||||
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
|
||||
snapshot.insertSections([.trendingLinks], beforeSection: .profileSuggestions)
|
||||
} else {
|
||||
snapshot.appendSections([.trendingLinks])
|
||||
}
|
||||
snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks)
|
||||
}
|
||||
|
||||
|
@ -207,7 +239,7 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
}
|
||||
|
||||
if !Task.isCancelled {
|
||||
await dataSource.apply(snapshot)
|
||||
await apply(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,6 +250,30 @@ class SearchViewController: UIViewController, CollectionViewController {
|
|||
}
|
||||
}
|
||||
|
||||
private func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>) async {
|
||||
await Task { @MainActor in
|
||||
self.dataSource.apply(snapshot)
|
||||
}.value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func removeProfileSuggestion(accountID: String) async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
var snapshot = dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(accountID, .global)])
|
||||
await apply(snapshot: snapshot)
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
_ = await self.removeProfileSuggestion(accountID: accountID)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
|
@ -244,6 +300,7 @@ extension SearchViewController {
|
|||
case status(String, CollapseState)
|
||||
case tag(Hashtag)
|
||||
case link(Card)
|
||||
case account(String, Suggestion.Source)
|
||||
|
||||
static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
|
@ -253,6 +310,8 @@ extension SearchViewController {
|
|||
return a == b
|
||||
case let (.link(a), .link(b)):
|
||||
return a.url == b.url
|
||||
case let (.account(a, _), .account(b, _)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -269,6 +328,9 @@ extension SearchViewController {
|
|||
case let .link(card):
|
||||
hasher.combine("link")
|
||||
hasher.combine(card.url)
|
||||
case let .account(id, _):
|
||||
hasher.combine("account")
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -290,6 +352,9 @@ extension SearchViewController: UICollectionViewDelegate {
|
|||
|
||||
case let .status(id, state):
|
||||
selected(status: id, state: state.copy())
|
||||
|
||||
case let .account(id, _):
|
||||
selected(account: id)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -330,6 +395,18 @@ extension SearchViewController: UICollectionViewDelegate {
|
|||
UIMenu(children: self.actionsForStatus(status, source: .view(cell)))
|
||||
}
|
||||
|
||||
case let .account(id, _):
|
||||
let cell = collectionView.cellForItem(at: indexPath)!
|
||||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in
|
||||
Task {
|
||||
await self.removeProfileSuggestion(accountID: id)
|
||||
}
|
||||
}
|
||||
return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -348,7 +425,7 @@ extension SearchViewController: UICollectionViewDelegate {
|
|||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfiguration configuration: UIContextMenuConfiguration, highlightPreviewForItemAt indexPath: IndexPath) -> UITargetedPreview? {
|
||||
switch dataSource.itemIdentifier(for: indexPath) {
|
||||
case .link(_):
|
||||
case .link(_), .account(_, _):
|
||||
guard let cell = collectionView.cellForItem(at: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
|
@ -399,6 +476,16 @@ extension SearchViewController: UICollectionViewDragDelegate {
|
|||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
|
||||
case let .account(id, _):
|
||||
guard let account = mastodonController.persistentContainer.account(for: id) else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: account.url as NSURL)
|
||||
let activity = UserActivityManager.showProfileActivity(id: id, accountID: mastodonController.accountInfo!.id)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue