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) 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 { 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 */; }; D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; };
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; }; D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; };
D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.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 */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
@ -895,6 +899,8 @@
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */, D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */,
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */, D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */,
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */, D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */,
D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */,
D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */,
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */, D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */, D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */, D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
@ -1806,6 +1812,7 @@
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */, D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */, D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */, D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
D601FA84297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib in Resources */,
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */, D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */, D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */, D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
@ -1994,6 +2001,7 @@
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,

View File

@ -51,6 +51,10 @@ struct InstanceFeatures {
instanceType.isMastodon instanceType.isMastodon
} }
var profileSuggestions: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
}
var trendingStatusesAndLinks: Bool { var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 5, 0) 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") Label("Delete", systemImage: "trash")
}.foregroundStyle(.red) }
} previewIfAvailable: { } previewIfAvailable: {
ComposeAttachmentImage(attachment: attachment, fullSize: true) ComposeAttachmentImage(attachment: attachment, fullSize: true)
} }

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import UIKit
class HashtagSearchResultsViewController: SearchResultsViewController { class HashtagSearchResultsViewController: SearchResultsViewController {
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
super.init(mastodonController: mastodonController, resultTypes: [.hashtags]) super.init(mastodonController: mastodonController, scope: .hashtags)
} }
required init?(coder: NSCoder) { 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 UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import SwiftSoup
class TrendingLinkCardCollectionViewCell: UICollectionViewCell { class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
private var card: Card? 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 titleLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!
@IBOutlet weak var providerLabel: UILabel! @IBOutlet weak var providerLabel: UILabel!
@IBOutlet weak var activityLabel: UILabel! @IBOutlet weak var activityLabel: UILabel!
@IBOutlet weak var historyView: TrendHistoryView! @IBOutlet weak var historyView: TrendHistoryView!
private var hoverGestureAnimator: UIViewPropertyAnimator?
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
thumbnailView.cache = .attachments
layer.shadowOpacity = 0.2 layer.shadowOpacity = 0.2
layer.shadowRadius = 8 layer.shadowRadius = 8
layer.shadowOffset = .zero layer.shadowOffset = .zero
layer.masksToBounds = false layer.masksToBounds = false
contentView.layer.cornerRadius = 12.5
updateLayerColors() updateLayerColors()
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
}
override func layoutSubviews() {
super.layoutSubviews()
contentView.layer.cornerRadius = 0.05 * bounds.width
thumbnailView.layer.cornerRadius = 0.05 * bounds.width
} }
func updateUI(card: Card) { func updateUI(card: Card) {
self.card = card self.card = card
self.thumbnailView.image = nil self.thumbnailView.image = nil
updateGrayscaleableUI(card: card) thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash)
updateUIForPreferences()
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title titleLabel.text = title
@ -53,6 +51,10 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines) let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
providerLabel.text = provider 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 sorted = card.history!.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...] let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +) let accounts = lastTwo.map(\.accounts).reduce(0, +)
@ -69,33 +71,6 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
historyView.isHidden = card.history == nil || card.history!.count < 2 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) { private func loadBlurHash(card: Card) {
guard let hash = card.blurhash else { guard let hash = card.blurhash else {
return return
@ -134,12 +109,30 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
private func updateLayerColors() { private func updateLayerColors() {
if traitCollection.userInterfaceStyle == .dark { if traitCollection.userInterfaceStyle == .dark {
// clippingView.layer.borderColor = UIColor.darkGray.withAlphaComponent(0.5).cgColor
layer.shadowColor = UIColor.darkGray.cgColor layer.shadowColor = UIColor.darkGray.cgColor
} else { } else {
// clippingView.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
layer.shadowColor = UIColor.black.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"?> <?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"/> <device id="retina6_0" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <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="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" 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"/> <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="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> <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"> <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"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Zb0-aW-Sen"> <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"/> <autoresizingMask key="autoresizingMask"/>
<subviews> <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"/> <rect key="frame" x="0.0" y="0.0" width="300" height="225"/>
<constraints> <constraints>
<constraint firstAttribute="width" secondItem="h3b-Mf-lD6" secondAttribute="height" multiplier="4:3" id="QDY-8a-LYC"/> <constraint firstAttribute="width" secondItem="h3b-Mf-lD6" secondAttribute="height" multiplier="4:3" id="QDY-8a-LYC"/>
</constraints> </constraints>
</imageView> </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"> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="LpU-m4-guC">
<rect key="frame" x="16" y="330.66666666666674" width="268" height="20.333333333333314"/> <rect key="frame" x="4" y="225" width="292" height="177"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> <subviews>
<nil key="textColor"/> <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">
<nil key="highlightedColor"/> <rect key="frame" x="0.0" y="0.0" width="83.333333333333329" height="142.33333333333334"/>
</label> <fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/>
<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"> <nil key="textColor"/>
<rect key="frame" x="16.000000000000004" y="355" width="57.333333333333343" height="18"/> <nil key="highlightedColor"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> </label>
<nil key="textColor"/> <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">
<nil key="highlightedColor"/> <rect key="frame" x="0.0" y="146.33333333333331" width="44" height="13.333333333333343"/>
</label> <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/>
<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"> <nil key="textColor"/>
<rect key="frame" x="16" y="377" width="43" height="15"/> <nil key="highlightedColor"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/> </label>
<nil key="textColor"/> <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">
<nil key="highlightedColor"/> <rect key="frame" x="0.0" y="163.66666666666669" width="39.333333333333336" height="13.333333333333343"/>
</label> <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LZj-Ii-63i" customClass="TrendHistoryView" customModule="Tusker" customModuleProvider="target"> <nil key="textColor"/>
<rect key="frame" x="200" y="355" width="100" height="44"/> <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> <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"/> <constraint firstAttribute="width" constant="100" id="cUc-p7-aLH"/>
</constraints> </constraints>
</view> </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> </subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="bottomMargin" secondItem="ULe-Gd-t1S" secondAttribute="bottom" id="6UL-8b-Aia"/> <constraint firstItem="cWo-9n-z42" firstAttribute="bottom" secondItem="h3b-Mf-lD6" secondAttribute="bottom" id="1iR-rH-KCl"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="top" secondItem="Zb0-aW-Sen" secondAttribute="top" id="EFg-Yr-vdt"/> <constraint firstItem="LpU-m4-guC" firstAttribute="top" secondItem="h3b-Mf-lD6" secondAttribute="bottom" id="4GW-Eu-47t"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Ga8-LQ-f4N"/> <constraint firstItem="h3b-Mf-lD6" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="8vO-xS-kkp"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="top" secondItem="O9r-10-LDD" secondAttribute="bottom" constant="4" id="HPD-qN-k3z"/> <constraint firstAttribute="bottom" secondItem="LpU-m4-guC" secondAttribute="bottom" constant="4" id="DUO-kl-ggb"/>
<constraint firstAttribute="bottom" secondItem="LZj-Ii-63i" secondAttribute="bottom" constant="1" id="HWu-In-Uem"/> <constraint firstAttribute="trailing" secondItem="cWo-9n-z42" secondAttribute="trailing" id="Deo-mN-Ir3"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Hz8-Bw-jpl"/> <constraint firstAttribute="trailing" secondItem="h3b-Mf-lD6" secondAttribute="trailing" id="Hjp-9H-VHN"/>
<constraint firstAttribute="trailing" secondItem="LZj-Ii-63i" secondAttribute="trailing" id="J9c-CF-3EF"/> <constraint firstAttribute="bottom" secondItem="LZj-Ii-63i" secondAttribute="bottom" constant="1" id="Lcx-ET-gnk"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="KEj-En-StX"/> <constraint firstAttribute="trailing" secondItem="LpU-m4-guC" secondAttribute="trailing" constant="4" id="UQ8-eo-L1G"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="top" secondItem="h3b-Mf-lD6" secondAttribute="bottom" constant="4" id="PjW-V1-oDs"/> <constraint firstItem="cWo-9n-z42" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="ai7-Qf-ksj"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="O9r-10-LDD" secondAttribute="trailing" id="WNr-ZP-o9a"/> <constraint firstAttribute="trailing" secondItem="LZj-Ii-63i" secondAttribute="trailing" id="dHE-zD-VvZ"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="fpM-Hp-Oyf"/> <constraint firstItem="LpU-m4-guC" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" constant="4" id="rdC-Sg-fwV"/>
<constraint firstAttribute="trailing" secondItem="h3b-Mf-lD6" secondAttribute="trailing" id="kBD-1R-bh7"/> <constraint firstItem="h3b-Mf-lD6" firstAttribute="top" secondItem="Zb0-aW-Sen" secondAttribute="top" id="z5T-J0-5RD"/>
<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"/>
</constraints> </constraints>
</collectionViewCellContentView> </collectionViewCellContentView>
<size key="customSize" width="300" height="406"/>
<connections> <connections>
<outlet property="activityLabel" destination="ULe-Gd-t1S" id="wqe-G6-IB3"/> <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="historyView" destination="LZj-Ii-63i" id="MVF-az-uyA"/>
<outlet property="providerLabel" destination="O9r-10-LDD" id="xAF-NW-ymm"/> <outlet property="providerLabel" destination="O9r-10-LDD" id="xAF-NW-ymm"/>
<outlet property="thumbnailView" destination="h3b-Mf-lD6" id="4mF-bJ-ALY"/> <outlet property="thumbnailView" destination="h3b-Mf-lD6" id="4mF-bJ-ALY"/>
<outlet property="titleLabel" destination="Ho3-cU-IGi" id="ltu-ey-chT"/> <outlet property="titleLabel" destination="Ho3-cU-IGi" id="ltu-ey-chT"/>
</connections> </connections>
<point key="canvasLocation" x="0.0" y="-13.507109004739336"/> <point key="canvasLocation" x="0.0" y="-11.374407582938389"/>
</collectionViewCell> </collectionViewCell>
</objects> </objects>
<resources> <resources>

View File

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

View File

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

View File

@ -232,7 +232,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
tabBarViewController.select(tab: .explore) tabBarViewController.select(tab: .explore)
case .bookmarks, .trendingStatuses, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_): case .bookmarks, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore) 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 // 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. // in compact mode and performing a search.
@ -277,6 +277,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case .explore: case .explore:
// The Explore tab is more complicated since it encapsulates a bunch of screens which have top-level sidebar items. // 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 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 // 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) exploreItem = .savedHashtag(hashtagVC.hashtag)
case let instanceVC as InstanceTimelineViewController: case let instanceVC as InstanceTimelineViewController:
exploreItem = .savedInstance(instanceVC.instanceURL) exploreItem = .savedInstance(instanceVC.instanceURL)
case is TrendingStatusesViewController: case is TrendingStatusesViewController, is TrendingHashtagsViewController, is TrendingLinksViewController:
exploreItem = .trendingStatuses
case is TrendingHashtagsViewController:
exploreItem = .explore
case is TrendingLinksViewController:
exploreItem = .explore exploreItem = .explore
// these three VCs are part of the root SearchViewController, so we don't need to transfer them
skipFirst = 2
case is ProfileDirectoryViewController: case is ProfileDirectoryViewController:
exploreItem = .profileDirectory exploreItem = .profileDirectory
default: default:
fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])") 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: case .compose:
// The compose tab can't be activated, this is unreachable. // The compose tab can't be activated, this is unreachable.
@ -376,8 +375,6 @@ fileprivate extension MainSidebarViewController.Item {
return SearchViewController(mastodonController: mastodonController) return SearchViewController(mastodonController: mastodonController)
case .bookmarks: case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController) return BookmarksTableViewController(mastodonController: mastodonController)
case .trendingStatuses:
return TrendingStatusesViewController(mastodonController: mastodonController)
case .profileDirectory: case .profileDirectory:
return ProfileDirectoryViewController(mastodonController: mastodonController) return ProfileDirectoryViewController(mastodonController: mastodonController)
case let .list(list): case let .list(list):

View File

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

View File

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

View File

@ -11,11 +11,11 @@ import Pachyderm
import SafariServices import SafariServices
import WebURLFoundationExtras import WebURLFoundationExtras
class SearchViewController: UIViewController { class SearchViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var collectionView: UICollectionView! var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController! var resultsController: SearchResultsViewController!
@ -23,6 +23,8 @@ class SearchViewController: UIViewController {
var searchControllerStatusOnAppearance: Bool? = nil var searchControllerStatusOnAppearance: Bool? = nil
private var loadTask: Task<Void, Never>?
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -47,10 +49,8 @@ class SearchViewController: UIViewController {
return .list(using: listConfig, layoutEnvironment: environment) return .list(using: listConfig, layoutEnvironment: environment)
case .trendingLinks: 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) 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 groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil) group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
@ -59,10 +59,27 @@ class SearchViewController: UIViewController {
section.boundarySupplementaryItems = [ section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) 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 return section
default: case .profileSuggestions:
fatalError("unimplemented") 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) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
@ -79,8 +96,12 @@ class SearchViewController: UIViewController {
resultsController.exploreNavigationController = self.navigationController resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController) searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = true
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true definesPresentationContext = true
@ -96,7 +117,10 @@ class SearchViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
Task(priority: .userInitiated) { clearSelectionOnAppear(animated: animated)
loadTask?.cancel()
loadTask = Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil { if (try? await mastodonController.getOwnInstance()) != nil {
await applySnapshot() 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 let trendingLinkCell = UICollectionView.CellRegistration<TrendingLinkCardCollectionViewCell, Card>(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in
cell.updateUI(card: card) 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 let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch item { switch item {
@ -138,8 +171,11 @@ class SearchViewController: UIViewController {
case let .link(card): case let .link(card):
return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card) return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card)
default: case let .status(id, state):
fatalError("todo") 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 dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
@ -161,30 +197,82 @@ class SearchViewController: UIViewController {
} }
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
let hashtagsReq = Client.getTrendingHashtags(limit: 5) let hashtagsReq = Client.getTrendingHashtags(limit: 5)
async let hashtags = try? mastodonController.run(hashtagsReq).0 let hashtags = try? await mastodonController.run(hashtagsReq).0
let linksReq = Client.getTrendingLinks(limit: 10)
async let links = try? mastodonController.run(linksReq).0 if let hashtags {
if let hashtags = await hashtags {
snapshot.appendSections([.trendingHashtags]) snapshot.appendSections([.trendingHashtags])
snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags) snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags)
} }
if let links = await links { if mastodonController.instanceFeatures.profileSuggestions {
snapshot.appendSections([.trendingLinks]) let req = Client.getSuggestions(limit: 10)
snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks) 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() { @objc private func preferencesChanged() {
Task { loadTask?.cancel()
loadTask = Task {
await applySnapshot() 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 { enum Section {
case trendingHashtags case trendingHashtags
case trendingLinks case trendingLinks
case trendingStatuses
case profileSuggestions case profileSuggestions
case trendingStatuses
var title: String { var title: String {
switch self { switch self {
@ -202,25 +290,28 @@ extension SearchViewController {
case .trendingLinks: case .trendingLinks:
return "Trending Links" return "Trending Links"
case .trendingStatuses: case .trendingStatuses:
return "Trending Statuses" return "Trending Posts"
case .profileSuggestions: case .profileSuggestions:
return "Suggested Accounts" return "Suggested Accounts"
} }
} }
} }
enum Item: Equatable, Hashable { enum Item: Equatable, Hashable {
case status(String) case status(String, CollapseState)
case tag(Hashtag) case tag(Hashtag)
case link(Card) case link(Card)
case account(String, Suggestion.Source)
static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool { static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.status(a), .status(b)): case let (.status(a, _), .status(b, _)):
return a == b return a == b
case let (.tag(a), .tag(b)): case let (.tag(a), .tag(b)):
return a == b return a == b
case let (.link(a), .link(b)): case let (.link(a), .link(b)):
return a.url == b.url return a.url == b.url
case let (.account(a, _), .account(b, _)):
return a == b
default: default:
return false return false
} }
@ -228,7 +319,7 @@ extension SearchViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .status(id): case let .status(id, _):
hasher.combine("status") hasher.combine("status")
hasher.combine(id) hasher.combine(id)
case let .tag(tag): case let .tag(tag):
@ -237,6 +328,9 @@ extension SearchViewController {
case let .link(card): case let .link(card):
hasher.combine("link") hasher.combine("link")
hasher.combine(card.url) hasher.combine(card.url)
case let .account(id, _):
hasher.combine("account")
hasher.combine(id)
} }
} }
} }
@ -256,11 +350,15 @@ extension SearchViewController: UICollectionViewDelegate {
selected(url: url) selected(url: url)
} }
default: case let .status(id, state):
fatalError("todo") 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? { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else { guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil return nil
@ -286,10 +384,63 @@ extension SearchViewController: UICollectionViewDelegate {
UIMenu(children: self.actionsForTrendingLink(card: card)) UIMenu(children: self.actionsForTrendingLink(card: card))
} }
default: case let .status(id, state):
fatalError("todo") 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 { extension SearchViewController: UICollectionViewDragDelegate {
@ -315,8 +466,26 @@ extension SearchViewController: UICollectionViewDragDelegate {
} }
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
default: case let .status(id, _):
fatalError("todo") 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: 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) 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() { @objc func toggleSaveButtonPressed() {
let context = parentMastodonController!.persistentContainer.viewContext let context = parentMastodonController!.persistentContainer.viewContext
let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!) 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) 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 { extension TimelineViewController {

View File

@ -384,7 +384,7 @@ extension MenuActionProvider {
private func openInSafariAction(url: URL) -> UIAction { private func openInSafariAction(url: URL) -> UIAction {
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { [weak self] (_) in 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( private let statusPathRegex = try! NSRegularExpression(
pattern: pattern:
"(^/@[a-z0-9_]+/\\d{18})" // mastodon "(^/@[a-z0-9_]+/\\d{18})" // mastodon
+ "|(^/notice/[a-z0-9]{18})" // pleroma + "|(^/notice/[a-z0-9]{18})" // pleroma
+ "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed + "|(^/notes/[a-z0-9]{10})" // misskey
+ "|(^/i/web/post/\\d{18})" // pixelfed web frontend + "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed
+ "|(^/u/.+/h/[a-z0-9]{18})" // honk + "|(^/i/web/post/\\d{18})" // pixelfed web frontend
+ "|(^/@.+/statuses/[a-z0-9]{26})" // gotosocial + "|(^/u/.+/h/[a-z0-9]{18})" // honk
+ "|(^/@.+/statuses/[a-z0-9]{26})" // gotosocial
, ,
options: .caseInsensitive options: .caseInsensitive
) )

View File

@ -10,26 +10,32 @@ import UIKit
class CachedImageView: UIImageView { class CachedImageView: UIImageView {
private let cache: ImageCache var cache: ImageCache!
private var url: URL? private var url: URL?
private var isGrayscale = false private var isGrayscale = false
private var fetchTask: Task<Void, Error>? private var fetchTask: Task<Void, Error>?
private var blurHashTask: DispatchWorkItem?
init(cache: ImageCache) { init(cache: ImageCache) {
self.cache = cache self.cache = cache
super.init(frame: .zero) super.init(frame: .zero)
commonInit()
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") super.init(coder: coder)
commonInit()
} }
func update(for url: URL?) { private func commonInit() {
if url != self.url { 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.url = url
self.image = nil
updateBlurhash(blurhash, for: url)
updateImage() 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() { private func updateImage() {
fetchTask?.cancel() fetchTask?.cancel()
guard let url else {
return
}
fetchTask = Task(priority: .high) { fetchTask = Task(priority: .high) {
self.image = nil
guard let url else {
return
}
let (_, image) = await cache.get(url) let (_, image) = await cache.get(url)
guard let image else { guard let image else {
return return
@ -59,6 +84,7 @@ class CachedImageView: UIImageView {
try Task.checkCancellation() try Task.checkCancellation()
self.image = transformedImage self.image = transformedImage
self.isGrayscale = Preferences.shared.grayscaleImages self.isGrayscale = Preferences.shared.grayscaleImages
self.blurHashTask?.cancel()
} }
} }

View File

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

View File

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

View File

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