Compare commits

...

18 Commits

Author SHA1 Message Date
Shadowfacts 2ee34acbad Fix remove attachment menu item not being marked destructive 2023-01-24 15:02:11 -05:00
Shadowfacts 6eee97759e Add context menu action to remove pinned timeline
Closes #334
2023-01-24 10:19:04 -05:00
Shadowfacts f88bf552af Reuse client ID/secret when trying to sign in to the same account again
Workaround for mastodon.social signins being flaky
2023-01-23 17:43:41 -05:00
Shadowfacts d2c7664073 Add profile suggestions to Explore on iPad 2023-01-23 17:10:26 -05:00
Shadowfacts e91249a876 Detect Misskey links properly 2023-01-23 16:59:24 -05:00
Shadowfacts 1eab964c0b Parse HTML in trending link card descriptions 2023-01-23 15:15:43 -05:00
Shadowfacts 2933ac491b Fix Open in Safari action not working 2023-01-23 10:35:23 -05:00
Shadowfacts 2958d2b1ac Change TrendingLinkCardCollectionViewCell to use CachedImageView 2023-01-22 18:21:58 -05:00
Shadowfacts 3262fe002b Add hover interaction to trending link cards 2023-01-22 17:37:41 -05:00
Shadowfacts 521e5ad5fc Make trend history view respond to preferred content size category 2023-01-22 17:23:22 -05:00
Shadowfacts 2b651b0bc4 Fix trending hashtag cells not adjusting to dynamic type 2023-01-22 17:23:19 -05:00
Shadowfacts 99b3532e64 Add description to trending link cards, fix not responding to dynamic type 2023-01-22 17:23:19 -05:00
Shadowfacts 2ea8e9cf1e Fix preview action on iPad Explore screen not working 2023-01-22 15:44:36 -05:00
Shadowfacts e8b7446117 Fix split view expand breaking when transferring trending statuses/hashtags/links VCs 2023-01-22 14:01:44 -05:00
Shadowfacts a47b9c0c75 Move trending statuses to Explore on iPad
See #171
2023-01-22 13:57:37 -05:00
Shadowfacts a75862b5cc Mask trending link card previews with same corner radius as cells 2023-01-22 12:08:22 -05:00
Shadowfacts 0738683ee3 Add search scopes
Closes #328
2023-01-22 11:41:38 -05:00
Shadowfacts 155f4036f9 Handle authentication required error for instance timelines 2023-01-22 11:18:43 -05:00
26 changed files with 904 additions and 174 deletions

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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 */,

View File

@ -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)
}

View File

@ -37,9 +37,9 @@ struct ComposeAttachmentRow: View {
}
}
Button(action: self.removeAttachment) {
Button(role: .destructive, action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}.foregroundStyle(.red)
}
} previewIfAvailable: {
ComposeAttachmentImage(attachment: attachment, fullSize: true)
}

View File

@ -45,6 +45,13 @@ struct PinnedTimelinesView: View {
.foregroundColor(Color(.lightGray))
.accessibilityHidden(true)
}
.contextMenu {
Button(role: .destructive) {
pinnedTimelines.removeAll(where: { $0.id == timeline.id })
} label: {
Label("Remove Pinned Timeline", systemImage: "trash")
}
}
}
.onMove { indices, newOffset in
pinnedTimelines.move(fromOffsets: indices, toOffset: newOffset)

View File

@ -64,8 +64,12 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = resultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
definesPresentationContext = true
navigationItem.searchController = searchController

View File

@ -11,7 +11,7 @@ import UIKit
class HashtagSearchResultsViewController: SearchResultsViewController {
init(mastodonController: MastodonController) {
super.init(mastodonController: mastodonController, resultTypes: [.hashtags])
super.init(mastodonController: mastodonController, scope: .hashtags)
}
required init?(coder: NSCoder) {

View File

@ -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()
}
}
}
}
}

View File

@ -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>

View File

@ -8,44 +8,42 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
import SwiftSoup
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
private var card: Card?
private var isGrayscale = false
private var thumbnailRequest: ImageCache.Request?
@IBOutlet weak var thumbnailView: UIImageView!
@IBOutlet weak var thumbnailView: CachedImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!
@IBOutlet weak var providerLabel: UILabel!
@IBOutlet weak var activityLabel: UILabel!
@IBOutlet weak var historyView: TrendHistoryView!
private var hoverGestureAnimator: UIViewPropertyAnimator?
override func awakeFromNib() {
super.awakeFromNib()
thumbnailView.cache = .attachments
layer.shadowOpacity = 0.2
layer.shadowRadius = 8
layer.shadowOffset = .zero
layer.masksToBounds = false
contentView.layer.cornerRadius = 12.5
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
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
}
func updateUI(card: Card) {
self.card = card
self.thumbnailView.image = nil
updateGrayscaleableUI(card: card)
updateUIForPreferences()
thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash)
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title
@ -53,6 +51,10 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
providerLabel.text = provider
let description = try! SwiftSoup.parseBodyFragment(card.description).text()
descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty
let sorted = card.history!.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)
@ -69,33 +71,6 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
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
@ -134,12 +109,30 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
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
}
}
// MARK: Interaction
@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
}
}
}

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21179.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<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_0" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21169.4"/>
<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"/>
@ -12,71 +12,98 @@
<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="izA-ZZ-g7F" customClass="TrendingLinkCardCollectionViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="300" height="400"/>
<rect key="frame" x="0.0" y="0.0" width="300" height="406"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Zb0-aW-Sen">
<rect key="frame" x="0.0" y="0.0" width="300" height="400"/>
<rect key="frame" x="0.0" y="0.0" width="300" height="406"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="h3b-Mf-lD6">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="h3b-Mf-lD6" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="300" height="225"/>
<constraints>
<constraint firstAttribute="width" secondItem="h3b-Mf-lD6" secondAttribute="height" multiplier="4:3" id="QDY-8a-LYC"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ho3-cU-IGi">
<rect key="frame" x="16" y="330.66666666666674" width="268" height="20.333333333333314"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Provider" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="O9r-10-LDD">
<rect key="frame" x="16.000000000000004" y="355" width="57.333333333333343" height="18"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Activity" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ULe-Gd-t1S">
<rect key="frame" x="16" y="377" width="43" height="15"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LZj-Ii-63i" customClass="TrendHistoryView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="200" y="355" width="100" height="44"/>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="LpU-m4-guC">
<rect key="frame" x="4" y="225" width="292" height="177"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Description" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0hs-Zm-eWF">
<rect key="frame" x="0.0" y="0.0" width="83.333333333333329" height="142.33333333333334"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Provider" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="O9r-10-LDD">
<rect key="frame" x="0.0" y="146.33333333333331" width="44" height="13.333333333333343"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Activity" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ULe-Gd-t1S">
<rect key="frame" x="0.0" y="163.66666666666669" width="39.333333333333336" height="13.333333333333343"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LZj-Ii-63i" customClass="TrendHistoryView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="200" y="361" width="100" height="44"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="100" id="LPx-cL-9b4"/>
<constraint firstAttribute="height" constant="44" id="cGp-dq-laF"/>
<constraint firstAttribute="width" constant="100" id="cUc-p7-aLH"/>
</constraints>
</view>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cWo-9n-z42">
<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.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">
<rect key="frame" x="4" y="4" width="292" height="20.333333333333332"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="leading" secondItem="ktv-3s-cp9" secondAttribute="leading" constant="4" id="igP-RK-yCM"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="top" secondItem="ktv-3s-cp9" secondAttribute="top" constant="4" id="oPU-19-ZAy"/>
<constraint firstAttribute="bottom" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="psK-H0-rhV"/>
<constraint firstAttribute="trailing" secondItem="Ho3-cU-IGi" secondAttribute="trailing" constant="4" id="v6n-yr-HLJ"/>
</constraints>
</view>
<blurEffect style="prominent"/>
</visualEffectView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="bottomMargin" secondItem="ULe-Gd-t1S" secondAttribute="bottom" id="6UL-8b-Aia"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="top" secondItem="Zb0-aW-Sen" secondAttribute="top" id="EFg-Yr-vdt"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Ga8-LQ-f4N"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="top" secondItem="O9r-10-LDD" secondAttribute="bottom" constant="4" id="HPD-qN-k3z"/>
<constraint firstAttribute="bottom" secondItem="LZj-Ii-63i" secondAttribute="bottom" constant="1" id="HWu-In-Uem"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Hz8-Bw-jpl"/>
<constraint firstAttribute="trailing" secondItem="LZj-Ii-63i" secondAttribute="trailing" id="J9c-CF-3EF"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="KEj-En-StX"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="top" secondItem="h3b-Mf-lD6" secondAttribute="bottom" constant="4" id="PjW-V1-oDs"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="O9r-10-LDD" secondAttribute="trailing" id="WNr-ZP-o9a"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="fpM-Hp-Oyf"/>
<constraint firstAttribute="trailing" secondItem="h3b-Mf-lD6" secondAttribute="trailing" id="kBD-1R-bh7"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ULe-Gd-t1S" secondAttribute="trailing" id="ruZ-p8-n0x"/>
<constraint firstAttribute="trailingMargin" secondItem="Ho3-cU-IGi" secondAttribute="trailing" id="ubj-f6-bXE"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="wF1-Gm-nVQ"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="yPq-dT-uib"/>
<constraint firstItem="cWo-9n-z42" firstAttribute="bottom" secondItem="h3b-Mf-lD6" secondAttribute="bottom" id="1iR-rH-KCl"/>
<constraint firstItem="LpU-m4-guC" firstAttribute="top" secondItem="h3b-Mf-lD6" secondAttribute="bottom" id="4GW-Eu-47t"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="8vO-xS-kkp"/>
<constraint firstAttribute="bottom" secondItem="LpU-m4-guC" secondAttribute="bottom" constant="4" id="DUO-kl-ggb"/>
<constraint firstAttribute="trailing" secondItem="cWo-9n-z42" secondAttribute="trailing" id="Deo-mN-Ir3"/>
<constraint firstAttribute="trailing" secondItem="h3b-Mf-lD6" secondAttribute="trailing" id="Hjp-9H-VHN"/>
<constraint firstAttribute="bottom" secondItem="LZj-Ii-63i" secondAttribute="bottom" constant="1" id="Lcx-ET-gnk"/>
<constraint firstAttribute="trailing" secondItem="LpU-m4-guC" secondAttribute="trailing" constant="4" id="UQ8-eo-L1G"/>
<constraint firstItem="cWo-9n-z42" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="ai7-Qf-ksj"/>
<constraint firstAttribute="trailing" secondItem="LZj-Ii-63i" secondAttribute="trailing" id="dHE-zD-VvZ"/>
<constraint firstItem="LpU-m4-guC" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" constant="4" id="rdC-Sg-fwV"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="top" secondItem="Zb0-aW-Sen" secondAttribute="top" id="z5T-J0-5RD"/>
</constraints>
</collectionViewCellContentView>
<size key="customSize" width="300" height="406"/>
<connections>
<outlet property="activityLabel" destination="ULe-Gd-t1S" id="wqe-G6-IB3"/>
<outlet property="descriptionLabel" destination="0hs-Zm-eWF" id="RSz-VV-OCw"/>
<outlet property="historyView" destination="LZj-Ii-63i" id="MVF-az-uyA"/>
<outlet property="providerLabel" destination="O9r-10-LDD" id="xAF-NW-ymm"/>
<outlet property="thumbnailView" destination="h3b-Mf-lD6" id="4mF-bJ-ALY"/>
<outlet property="titleLabel" destination="Ho3-cU-IGi" id="ltu-ey-chT"/>
</connections>
<point key="canvasLocation" x="0.0" y="-13.507109004739336"/>
<point key="canvasLocation" x="0.0" y="-11.374407582938389"/>
</collectionViewCell>
</objects>
<resources>

View File

@ -65,7 +65,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
})
dataSource.editListAccountsController = self
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people)
searchResultsController.following = true
searchResultsController.delegate = self
searchController = UISearchController(searchResultsController: searchResultsController)

View File

@ -41,7 +41,7 @@ class MainSidebarViewController: UIViewController {
}
var exploreTabItems: [Item] {
var items: [Item] = [.explore, .bookmarks, .trendingStatuses, .profileDirectory]
var items: [Item] = [.explore, .bookmarks, .profileDirectory]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
@ -195,9 +195,6 @@ class MainSidebarViewController: UIViewController {
discoverSnapshot.append([
.profileDirectory,
], to: .discoverHeader)
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
discoverSnapshot.insert([.trendingStatuses], before: .profileDirectory)
}
dataSource.apply(discoverSnapshot, to: .discover)
}
@ -388,7 +385,7 @@ extension MainSidebarViewController {
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case explore, bookmarks
case discoverHeader, trendingStatuses, profileDirectory
case discoverHeader, profileDirectory
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
@ -403,8 +400,6 @@ extension MainSidebarViewController {
return "Bookmarks"
case .discoverHeader:
return "Discover"
case .trendingStatuses:
return "Trending Posts"
case .profileDirectory:
return "Profile Directory"
case .listsHeader:
@ -436,8 +431,6 @@ extension MainSidebarViewController {
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .trendingStatuses:
return "square.text.square"
case .profileDirectory:
return "person.2.fill"
case .list(_):

View File

@ -232,7 +232,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
tabBarViewController.select(tab: .explore)
case .bookmarks, .trendingStatuses, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
case .bookmarks, .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.
@ -277,6 +277,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case .explore:
// The Explore tab is more complicated since it encapsulates a bunch of screens which have top-level sidebar items.
var skipFirst = 1
var toPrepend: UIViewController? = nil
// If the tab navigation stack has only one item or the search controller is active, it corresponds to the Search item
@ -308,19 +309,17 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
exploreItem = .savedHashtag(hashtagVC.hashtag)
case let instanceVC as InstanceTimelineViewController:
exploreItem = .savedInstance(instanceVC.instanceURL)
case is TrendingStatusesViewController:
exploreItem = .trendingStatuses
case is TrendingHashtagsViewController:
exploreItem = .explore
case is TrendingLinksViewController:
case is TrendingStatusesViewController, is TrendingHashtagsViewController, is TrendingLinksViewController:
exploreItem = .explore
// these three VCs are part of the root SearchViewController, so we don't need to transfer them
skipFirst = 2
case is ProfileDirectoryViewController:
exploreItem = .profileDirectory
default:
fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])")
}
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: skipFirst, prepend: toPrepend)
case .compose:
// The compose tab can't be activated, this is unreachable.
@ -376,8 +375,6 @@ fileprivate extension MainSidebarViewController.Item {
return SearchViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController)
case .trendingStatuses:
return TrendingStatusesViewController(mastodonController: mastodonController)
case .profileDirectory:
return ProfileDirectoryViewController(mastodonController: mastodonController)
case let .list(list):

View File

@ -23,6 +23,8 @@ class OnboardingViewController: UINavigationController {
var authenticationSession: ASWebAuthenticationSession?
private var clientInfo: (url: URL, id: String, secret: String)?
init() {
super.init(rootViewController: instanceSelector)
}
@ -42,10 +44,16 @@ class OnboardingViewController: UINavigationController {
let mastodonController = MastodonController(instanceURL: instanceURL, transient: true)
let clientID: String
let clientSecret: String
do {
(clientID, clientSecret) = try await mastodonController.registerApp()
} catch {
throw Error.registeringApp(error)
if let clientInfo, clientInfo.url == instanceURL {
clientID = clientInfo.id
clientSecret = clientInfo.secret
} else {
do {
(clientID, clientSecret) = try await mastodonController.registerApp()
self.clientInfo = (instanceURL, clientID, clientSecret)
} catch {
throw Error.registeringApp(error)
}
}
let authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID)
let accessToken: String

View File

@ -38,17 +38,17 @@ class SearchResultsViewController: EnhancedTableViewController {
private var activityIndicator: UIActivityIndicatorView!
private var errorLabel: UILabel!
/// Types of results to search for. `nil` means all results will be included.
var resultTypes: [SearchResultType]? = nil
/// Types of results to search for.
var scope: Scope
/// Whether to limit results to accounts the users is following.
var following: Bool? = nil
let searchSubject = PassthroughSubject<String?, Never>()
var currentQuery: String?
init(mastodonController: MastodonController, resultTypes: [SearchResultType]? = nil) {
init(mastodonController: MastodonController, scope: Scope = .all) {
self.mastodonController = mastodonController
self.resultTypes = resultTypes
self.scope = scope
super.init(style: .grouped)
@ -153,7 +153,7 @@ class SearchResultsViewController: EnhancedTableViewController {
activityIndicator.startAnimating()
errorLabel.isHidden = true
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10, following: following)
let request = Client.search(query: query, types: scope.resultTypes, resolve: true, limit: 10, following: following)
mastodonController.run(request) { (response) in
switch response {
case let .success(results, _):
@ -178,17 +178,17 @@ class SearchResultsViewController: EnhancedTableViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
let resultTypes = self.resultTypes
if !results.accounts.isEmpty && (resultTypes == nil || resultTypes!.contains(.accounts)) {
let resultTypes = self.scope.resultTypes
if !results.accounts.isEmpty && resultTypes.contains(.accounts) {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
addAccounts(results.accounts)
}
if !results.hashtags.isEmpty && (resultTypes == nil || resultTypes!.contains(.hashtags)) {
if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) {
snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if !results.statuses.isEmpty && (resultTypes == nil || resultTypes!.contains(.statuses)) {
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
addStatuses(results.statuses)
@ -252,6 +252,41 @@ class SearchResultsViewController: EnhancedTableViewController {
}
extension SearchResultsViewController {
enum Scope: CaseIterable {
case all
case people
case hashtags
case posts
var title: String {
switch self {
case .all:
return "All"
case .people:
return "People"
case .hashtags:
return "Hashtags"
case .posts:
return "Posts"
}
}
var resultTypes: [SearchResultType] {
switch self {
case .all:
return [.accounts, .statuses, .hashtags]
case .people:
return [.accounts]
case .hashtags:
return [.hashtags]
case .posts:
return [.statuses]
}
}
}
}
extension SearchResultsViewController {
enum Section: CaseIterable {
case accounts
@ -311,6 +346,11 @@ extension SearchResultsViewController: UISearchBarDelegate {
// perform a search immedaitely when the search button is pressed
performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines))
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
self.scope = Scope.allCases[selectedScope]
performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
extension SearchResultsViewController: TuskerNavigationDelegate {

View File

@ -11,11 +11,11 @@ import Pachyderm
import SafariServices
import WebURLFoundationExtras
class SearchViewController: UIViewController {
class SearchViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
private var collectionView: UICollectionView!
var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController!
@ -23,6 +23,8 @@ class SearchViewController: UIViewController {
var searchControllerStatusOnAppearance: Bool? = nil
private var loadTask: Task<Void, Never>?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
@ -47,10 +49,8 @@ class SearchViewController: UIViewController {
return .list(using: listConfig, layoutEnvironment: environment)
case .trendingLinks:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280))
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)
@ -59,10 +59,27 @@ class SearchViewController: UIViewController {
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
default:
fatalError("unimplemented")
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 .list(using: listConfig, layoutEnvironment: environment)
}
}
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
@ -79,8 +96,12 @@ class SearchViewController: UIViewController {
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true
@ -96,7 +117,10 @@ class SearchViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task(priority: .userInitiated) {
clearSelectionOnAppear(animated: animated)
loadTask?.cancel()
loadTask = Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil {
await applySnapshot()
}
@ -129,6 +153,15 @@ class SearchViewController: UIViewController {
let trendingLinkCell = UICollectionView.CellRegistration<TrendingLinkCardCollectionViewCell, Card>(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in
cell.updateUI(card: card)
}
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
cell.delegate = self
// 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 {
@ -138,8 +171,11 @@ class SearchViewController: UIViewController {
case let .link(card):
return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card)
default:
fatalError("todo")
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
@ -161,30 +197,82 @@ class SearchViewController: UIViewController {
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
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 {
let hashtags = try? await mastodonController.run(hashtagsReq).0
if let 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)
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
let statusesReq = Client.getTrendingStatuses(limit: 10)
async let statuses = try? mastodonController.run(statusesReq).0
if let links = await links {
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
snapshot.insertSections([.trendingLinks], beforeSection: .profileSuggestions)
} else {
snapshot.appendSections([.trendingLinks])
}
snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks)
}
if let statuses = await statuses {
await mastodonController.persistentContainer.addAll(statuses: statuses)
snapshot.appendSections([.trendingStatuses])
snapshot.appendItems(statuses.map { .status($0.id, .unknown) }, toSection: .trendingStatuses)
}
}
if !Task.isCancelled {
await apply(snapshot: snapshot)
}
await dataSource.apply(snapshot)
}
@objc private func preferencesChanged() {
Task {
loadTask?.cancel()
loadTask = Task {
await applySnapshot()
}
}
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)
}
}
}
@ -192,8 +280,8 @@ extension SearchViewController {
enum Section {
case trendingHashtags
case trendingLinks
case trendingStatuses
case profileSuggestions
case trendingStatuses
var title: String {
switch self {
@ -202,25 +290,28 @@ extension SearchViewController {
case .trendingLinks:
return "Trending Links"
case .trendingStatuses:
return "Trending Statuses"
return "Trending Posts"
case .profileSuggestions:
return "Suggested Accounts"
}
}
}
enum Item: Equatable, Hashable {
case status(String)
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) {
case let (.status(a), .status(b)):
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
case let (.account(a, _), .account(b, _)):
return a == b
default:
return false
}
@ -228,7 +319,7 @@ extension SearchViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id):
case let .status(id, _):
hasher.combine("status")
hasher.combine(id)
case let .tag(tag):
@ -237,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)
}
}
}
@ -256,11 +350,15 @@ extension SearchViewController: UICollectionViewDelegate {
selected(url: url)
}
default:
fatalError("todo")
case let .status(id, state):
selected(status: id, state: state.copy())
case let .account(id, _):
selected(account: id)
}
}
@available(iOS, obsoleted: 16.0)
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil
@ -286,10 +384,63 @@ extension SearchViewController: UICollectionViewDelegate {
UIMenu(children: self.actionsForTrendingLink(card: card))
}
default:
fatalError("todo")
case let .status(id, state):
guard let status = mastodonController.persistentContainer.status(for: id) else {
return nil
}
let cell = collectionView.cellForItem(at: indexPath)!
return UIContextMenuConfiguration {
ConversationViewController(for: id, state: state.copy(), mastodonController: self.mastodonController)
} actionProvider: { _ in
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)))
}
}
}
// implementing the highlightPreviewForItemAt method seems to prevent the old, single IndexPath variant of this method from being called on iOS 16
@available(iOS 16.0, *)
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
guard indexPaths.count == 1 else {
return nil
}
return self.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPaths[0], point: point)
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfiguration configuration: UIContextMenuConfiguration, highlightPreviewForItemAt indexPath: IndexPath) -> UITargetedPreview? {
switch dataSource.itemIdentifier(for: indexPath) {
case .link(_), .account(_, _):
guard let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
let params = UIPreviewParameters()
params.visiblePath = UIBezierPath(roundedRect: cell.bounds, cornerRadius: cell.contentView.layer.cornerRadius)
return UITargetedPreview(view: cell, parameters: params)
default:
return nil
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfiguration configuration: UIContextMenuConfiguration, dismissalPreviewForItemAt indexPath: IndexPath) -> UITargetedPreview? {
return self.collectionView(collectionView, contextMenuConfiguration: configuration, highlightPreviewForItemAt: indexPath)
}
}
extension SearchViewController: UICollectionViewDragDelegate {
@ -315,8 +466,26 @@ extension SearchViewController: UICollectionViewDragDelegate {
}
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
default:
fatalError("todo")
case let .status(id, _):
guard let status = mastodonController.persistentContainer.status(for: id),
let url = status.url else {
return []
}
let provider = NSItemProvider(object: url as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: id, accountID: mastodonController.accountInfo!.id)
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)]
}
}
}
@ -330,3 +499,17 @@ extension SearchViewController: ToastableViewController {
extension SearchViewController: MenuActionProvider {
}
extension SearchViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
// TODO: filtering
}
}

View File

@ -81,7 +81,53 @@ class InstanceTimelineViewController: TimelineViewController {
super.collectionView(collectionView, didSelectItemAt: indexPath)
}
// MARK: - Interaction
// MARK: Timeline
override func handleLoadAllError(_ error: Swift.Error) async {
switch (error as? Client.Error)?.type {
case .mastodonError(422, _), .unexpectedStatus(422):
collectionView.isHidden = true
view.backgroundColor = .systemBackground
let image = UIImageView(image: UIImage(systemName: "lock.fill"))
image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit
let title = UILabel()
title.textColor = .secondaryLabel
title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
title.adjustsFontForContentSizeCategory = true
title.numberOfLines = 0
title.textAlignment = .center
title.text = "This instance requires an account to view."
let stack = UIStackView(arrangedSubviews: [
image,
title,
])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 8
stack.isAccessibilityElement = true
stack.accessibilityLabel = title.text!
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
image.widthAnchor.constraint(equalToConstant: 64),
image.heightAnchor.constraint(equalToConstant: 64),
stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
default:
await super.handleLoadAllError(error)
}
}
// MARK: Interaction
@objc func toggleSaveButtonPressed() {
let context = parentMastodonController!.persistentContainer.viewContext
let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!)

View File

@ -856,6 +856,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
// this is only implemented here so it's overridable by InstanceTimelineViewController
func handleLoadAllError(_ error: Swift.Error) async {
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadInitial()
}
}
self.showToast(configuration: config, animated: true)
}
}
extension TimelineViewController {

View File

@ -384,7 +384,7 @@ extension MenuActionProvider {
private func openInSafariAction(url: URL) -> UIAction {
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { [weak self] (_) in
self?.navigationDelegate?.selected(url: url, allowUniversalLinks: false)
self?.navigationDelegate?.selected(url: url, allowResolveStatuses: false, allowUniversalLinks: false)
})
}

View File

@ -224,12 +224,13 @@ enum PopoverSource {
private let statusPathRegex = try! NSRegularExpression(
pattern:
"(^/@[a-z0-9_]+/\\d{18})" // mastodon
+ "|(^/notice/[a-z0-9]{18})" // pleroma
+ "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed
+ "|(^/i/web/post/\\d{18})" // pixelfed web frontend
+ "|(^/u/.+/h/[a-z0-9]{18})" // honk
+ "|(^/@.+/statuses/[a-z0-9]{26})" // gotosocial
"(^/@[a-z0-9_]+/\\d{18})" // mastodon
+ "|(^/notice/[a-z0-9]{18})" // pleroma
+ "|(^/notes/[a-z0-9]{10})" // misskey
+ "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed
+ "|(^/i/web/post/\\d{18})" // pixelfed web frontend
+ "|(^/u/.+/h/[a-z0-9]{18})" // honk
+ "|(^/@.+/statuses/[a-z0-9]{26})" // gotosocial
,
options: .caseInsensitive
)

View File

@ -10,26 +10,32 @@ import UIKit
class CachedImageView: UIImageView {
private let cache: ImageCache
var cache: ImageCache!
private var url: URL?
private var isGrayscale = false
private var fetchTask: Task<Void, Error>?
private var blurHashTask: DispatchWorkItem?
init(cache: ImageCache) {
self.cache = cache
super.init(frame: .zero)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
commonInit()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
super.init(coder: coder)
commonInit()
}
func update(for url: URL?) {
if url != self.url {
private func commonInit() {
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
func update(for url: URL?, blurhash: String? = nil) {
if url != self.url || (url != nil && self.image == nil) {
self.url = url
self.image = nil
updateBlurhash(blurhash, for: url)
updateImage()
}
}
@ -40,13 +46,32 @@ class CachedImageView: UIImageView {
}
}
private func updateBlurhash(_ blurhash: String?, for url: URL?) {
blurHashTask?.cancel()
if let blurhash {
let aspectRatio = self.bounds.width > 0 ? self.bounds.height / self.bounds.width : 1
blurHashTask = DispatchWorkItem {
let size = CGSize(width: 60, height: aspectRatio * 60)
let image = UIImage(blurHash: blurhash, size: size)
DispatchQueue.main.async {
if self.image == nil && self.url == url && self.blurHashTask?.isCancelled == false {
self.image = image
}
}
}
AttachmentView.queue.async(execute: blurHashTask!)
}
}
private func updateImage() {
fetchTask?.cancel()
guard let url else {
return
}
fetchTask = Task(priority: .high) {
self.image = nil
guard let url else {
return
}
let (_, image) = await cache.get(url)
guard let image else {
return
@ -59,6 +84,7 @@ class CachedImageView: UIImageView {
try Task.checkCancellation()
self.image = transformedImage
self.isGrayscale = Preferences.shared.grayscaleImages
self.blurHashTask?.cancel()
}
}

View File

@ -21,7 +21,9 @@ class TrendingHashtagCollectionViewCell: UICollectionViewCell {
backgroundColor = .systemBackground
hashtagLabel.font = .preferredFont(forTextStyle: .title2)
hashtagLabel.adjustsFontForContentSizeCategory = true
peopleTodayLabel.font = .preferredFont(forTextStyle: .caption1)
peopleTodayLabel.adjustsFontForContentSizeCategory = true
let vStack = UIStackView(arrangedSubviews: [
hashtagLabel,

View File

@ -10,6 +10,7 @@ import UIKit
import Pachyderm
import SafariServices
import WebURLFoundationExtras
import SwiftSoup
class StatusCardView: UIView {
@ -156,7 +157,7 @@ class StatusCardView: UIView {
titleLabel.text = title
titleLabel.isHidden = title.isEmpty
let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines)
let description = try! SwiftSoup.parseBodyFragment(card.description).text()
descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty

View File

@ -97,7 +97,7 @@ class TrendHistoryView: UIView {
borderLayer.path = path.cgPath.copy()!
borderLayer.strokeColor = tintColor.cgColor
borderLayer.fillColor = nil
borderLayer.lineWidth = 2
borderLayer.lineWidth = lineWidth
borderLayer.lineCap = .round
path.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
@ -109,12 +109,16 @@ class TrendHistoryView: UIView {
let fillColor = self.fillColor()
fillLayer.strokeColor = fillColor
fillLayer.fillColor = fillColor
fillLayer.lineWidth = 2
fillLayer.lineWidth = lineWidth
layer.addSublayer(fillLayer)
layer.addSublayer(borderLayer)
}
private var lineWidth: CGFloat {
(traitCollection.preferredContentSizeCategory > .large || UIAccessibility.isBoldTextEnabled) ? 4 : 2
}
// The non-transparent fill color.
// We blend with the view's background color ourselves so that final color is non-transparent,
// otherwise when the fill layer's border and fill overlap, there's a visibly darker patch