Move trending hashtags/links to Explore tab on iPad

This commit is contained in:
Shadowfacts 2022-06-30 19:26:28 -07:00
parent 700cc2c67c
commit 5cb25c8c1f
10 changed files with 553 additions and 46 deletions

View File

@ -289,6 +289,8 @@
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; };
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; };
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */; };
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; };
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; };
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
@ -641,6 +643,8 @@
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
D6E57FA425C26FAB00341037 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = "<group>"; };
D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
@ -794,6 +798,8 @@
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */,
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */,
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */,
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */,
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */,
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
@ -1624,6 +1630,7 @@
buildActionMask = 2147483647;
files = (
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */,
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
@ -1912,6 +1919,7 @@
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,

View File

@ -48,7 +48,7 @@ class MastodonController: ObservableObject {
@Published private(set) var instanceFeatures = InstanceFeatures()
private(set) var customEmojis: [Emoji]?
private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool {
@ -159,15 +159,28 @@ class MastodonController: ObservableObject {
}
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0, completion: completion)
getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 {
completion?(instance)
}
}
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) {
@MainActor
func getOwnInstance() async throws -> Instance {
return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result)
}
})
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
if let instance = self.instance {
completion?(instance)
completion?(.success(instance))
} else {
if let completion = completion {
pendingOwnInstanceRequestCallbacks.append(completion)
@ -177,7 +190,7 @@ class MastodonController: ObservableObject {
let request = Client.getInstance()
ownInstanceRequest = run(request) { (response) in
switch response {
case .failure(_):
case .failure(let error):
let delay: DispatchTimeInterval
switch retryAttempt {
case 0:
@ -190,6 +203,10 @@ class MastodonController: ObservableObject {
delay = .seconds(60)
default:
// if we've failed four times, just give up :/
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.failure(error))
}
self.pendingOwnInstanceRequestCallbacks = []
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
@ -204,7 +221,7 @@ class MastodonController: ObservableObject {
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(instance)
completion(.success(instance))
}
self.pendingOwnInstanceRequestCallbacks = []
}

View File

@ -22,7 +22,7 @@ struct MenuController {
let data: Any
if case let .tab(tab) = item {
data = tab.rawValue
} else if case .search = item {
} else if case .explore = item {
data = "search"
} else if case .bookmarks = item {
data = "bookmarks"
@ -42,7 +42,7 @@ struct MenuController {
static let sidebarItemKeyCommands: [UIKeyCommand] = [
sidebarCommand(item: .tab(.timelines), command: "1"),
sidebarCommand(item: .tab(.notifications), command: "2"),
sidebarCommand(item: .search, command: "3"),
sidebarCommand(item: .explore, command: "3"),
sidebarCommand(item: .bookmarks, command: "4"),
sidebarCommand(item: .tab(.myProfile), command: "5"),
]

View File

@ -34,6 +34,10 @@ struct InstanceFeatures {
instanceType != .pixelfed
}
var trends: Bool {
instanceType == .mastodon
}
var trendingStatusesAndLinks: Bool {
instanceType == .mastodon && version != nil && version! >= Version(3, 5, 0)
}

View File

@ -0,0 +1,141 @@
//
// TrendingLinkCardCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 6/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
private var card: Card?
private var isGrayscale = false
private var thumbnailRequest: ImageCache.Request?
@IBOutlet weak var thumbnailView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var providerLabel: UILabel!
@IBOutlet weak var activityLabel: UILabel!
@IBOutlet weak var historyView: TrendHistoryView!
override func awakeFromNib() {
super.awakeFromNib()
layer.shadowOpacity = 0.2
layer.shadowRadius = 8
layer.shadowOffset = .zero
layer.masksToBounds = false
updateLayerColors()
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
override func layoutSubviews() {
super.layoutSubviews()
contentView.layer.cornerRadius = 0.05 * bounds.width
thumbnailView.layer.cornerRadius = 0.05 * bounds.width
}
func updateUI(card: Card) {
self.card = card
self.thumbnailView.image = nil
updateGrayscaleableUI(card: card)
updateUIForPreferences()
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
providerLabel.text = provider
let sorted = card.history!.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)
let uses = lastTwo.map(\.uses).reduce(0, +)
// U+2009 THIN SPACE
let activityStr = NSMutableAttributedString(string: "\(accounts.formatted())\u{2009}")
activityStr.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "person")!)))
activityStr.append(NSAttributedString(string: ", \(uses.formatted())\u{2009}"))
activityStr.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "square.text.square")!)))
activityLabel.attributedText = activityStr
historyView.setHistory(card.history)
historyView.isHidden = card.history == nil || card.history!.count < 2
}
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {
isGrayscale = Preferences.shared.grayscaleImages
if let imageURL = card.image,
let url = URL(imageURL) {
thumbnailRequest = ImageCache.attachments.get(url, completion: { _, image in
guard let image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
return
}
DispatchQueue.main.async {
self.thumbnailView.image = transformedImage
}
})
if thumbnailRequest != nil {
loadBlurHash(card: card)
}
}
}
private func loadBlurHash(card: Card) {
guard let hash = card.blurhash else {
return
}
let imageViewSize = self.thumbnailView.bounds.size
AttachmentView.queue.async { [weak self] in
let size: CGSize
if let width = card.width, let height = card.height {
size = CGSize(width: width, height: height)
} else {
size = imageViewSize
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self,
self.card?.url == card.url,
self.thumbnailView.image == nil else {
return
}
self.thumbnailView.image = preview
}
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateLayerColors()
}
private func updateLayerColors() {
if traitCollection.userInterfaceStyle == .dark {
// clippingView.layer.borderColor = UIColor.darkGray.withAlphaComponent(0.5).cgColor
layer.shadowColor = UIColor.darkGray.cgColor
} else {
// clippingView.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
layer.shadowColor = UIColor.black.cgColor
}
}
}

View File

@ -0,0 +1,87 @@
<?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">
<device id="retina6_0" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21169.4"/>
<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="izA-ZZ-g7F" customClass="TrendingLinkCardCollectionViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="300" height="400"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Zb0-aW-Sen">
<rect key="frame" x="0.0" y="0.0" width="300" height="400"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="h3b-Mf-lD6">
<rect key="frame" x="0.0" y="0.0" width="300" height="225"/>
<constraints>
<constraint firstAttribute="width" secondItem="h3b-Mf-lD6" secondAttribute="height" multiplier="4:3" id="QDY-8a-LYC"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ho3-cU-IGi">
<rect key="frame" x="16" y="330.66666666666674" width="268" height="20.333333333333314"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Provider" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="O9r-10-LDD">
<rect key="frame" x="16.000000000000004" y="355" width="57.333333333333343" height="18"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Activity" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ULe-Gd-t1S">
<rect key="frame" x="16" y="377" width="43" height="15"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LZj-Ii-63i" customClass="TrendHistoryView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="200" y="355" width="100" height="44"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="cUc-p7-aLH"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="bottomMargin" secondItem="ULe-Gd-t1S" secondAttribute="bottom" id="6UL-8b-Aia"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="top" secondItem="Zb0-aW-Sen" secondAttribute="top" id="EFg-Yr-vdt"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Ga8-LQ-f4N"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="top" secondItem="O9r-10-LDD" secondAttribute="bottom" constant="4" id="HPD-qN-k3z"/>
<constraint firstAttribute="bottom" secondItem="LZj-Ii-63i" secondAttribute="bottom" constant="1" id="HWu-In-Uem"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Hz8-Bw-jpl"/>
<constraint firstAttribute="trailing" secondItem="LZj-Ii-63i" secondAttribute="trailing" id="J9c-CF-3EF"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="KEj-En-StX"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="top" secondItem="h3b-Mf-lD6" secondAttribute="bottom" constant="4" id="PjW-V1-oDs"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="O9r-10-LDD" secondAttribute="trailing" id="WNr-ZP-o9a"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="fpM-Hp-Oyf"/>
<constraint firstAttribute="trailing" secondItem="h3b-Mf-lD6" secondAttribute="trailing" id="kBD-1R-bh7"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ULe-Gd-t1S" secondAttribute="trailing" id="ruZ-p8-n0x"/>
<constraint firstAttribute="trailingMargin" secondItem="Ho3-cU-IGi" secondAttribute="trailing" id="ubj-f6-bXE"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="wF1-Gm-nVQ"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="yPq-dT-uib"/>
</constraints>
</collectionViewCellContentView>
<connections>
<outlet property="activityLabel" destination="ULe-Gd-t1S" id="wqe-G6-IB3"/>
<outlet property="historyView" destination="LZj-Ii-63i" id="MVF-az-uyA"/>
<outlet property="providerLabel" destination="O9r-10-LDD" id="xAF-NW-ymm"/>
<outlet property="thumbnailView" destination="h3b-Mf-lD6" id="4mF-bJ-ALY"/>
<outlet property="titleLabel" destination="Ho3-cU-IGi" id="ltu-ey-chT"/>
</connections>
<point key="canvasLocation" x="0.0" y="-13.507109004739336"/>
</collectionViewCell>
</objects>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -113,6 +113,10 @@ class TrendingLinkTableViewCell: UITableViewCell {
}
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {

View File

@ -37,7 +37,7 @@ class MainSidebarViewController: UIViewController {
}
var exploreTabItems: [Item] {
var items: [Item] = [.search, .bookmarks, .trendingStatuses, .trendingTags, .trendingLinks, .profileDirectory]
var items: [Item] = [.explore, .bookmarks, .trendingStatuses, .profileDirectory]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
@ -154,7 +154,7 @@ class MainSidebarViewController: UIViewController {
snapshot.appendItems([
.tab(.timelines),
.tab(.notifications),
.search,
.explore,
.bookmarks,
.tab(.myProfile)
], toSection: .tabs)
@ -177,12 +177,10 @@ class MainSidebarViewController: UIViewController {
var discoverSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
discoverSnapshot.append([.discoverHeader])
discoverSnapshot.append([
.trendingTags,
.profileDirectory,
], to: .discoverHeader)
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
discoverSnapshot.insert([.trendingStatuses], before: .trendingTags)
discoverSnapshot.insert([.trendingLinks], after: .trendingTags)
discoverSnapshot.insert([.trendingStatuses], before: .profileDirectory)
}
dataSource.apply(discoverSnapshot, to: .discover)
}
@ -345,7 +343,7 @@ class MainSidebarViewController: UIViewController {
return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode)
case .tab(.compose):
return UserActivityManager.newPostActivity(accountID: id)
case .search:
case .explore:
return UserActivityManager.searchActivity()
case .bookmarks:
return UserActivityManager.bookmarksActivity()
@ -384,8 +382,8 @@ extension MainSidebarViewController {
}
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case search, bookmarks
case discoverHeader, trendingStatuses, trendingTags, trendingLinks, profileDirectory
case explore, bookmarks
case discoverHeader, trendingStatuses, profileDirectory
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
@ -394,18 +392,14 @@ extension MainSidebarViewController {
switch self {
case let .tab(tab):
return tab.title
case .search:
return "Search"
case .explore:
return "Explore"
case .bookmarks:
return "Bookmarks"
case .discoverHeader:
return "Discover"
case .trendingStatuses:
return "Trending Posts"
case .trendingTags:
return "Trending Hashtags"
case .trendingLinks:
return "Trending Links"
case .profileDirectory:
return "Profile Directory"
case .listsHeader:
@ -433,16 +427,12 @@ extension MainSidebarViewController {
switch self {
case let .tab(tab):
return tab.imageName
case .search:
case .explore:
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .trendingStatuses:
return "doc.text.image"
case .trendingTags:
return "number"
case .trendingLinks:
return "link"
return "square.text.square"
case .profileDirectory:
return "person.2.fill"
case .list(_):

View File

@ -100,7 +100,7 @@ class MainSplitViewController: UISplitViewController {
item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
} else if let str = command.propertyList as? String {
if str == "search" {
item = .search
item = .explore
} else if str == "bookmarks" {
item = .bookmarks
} else {
@ -171,7 +171,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
$0.1 > $1.1
}
if let mostRecentExploreItem = mostRecentExploreItem?.0,
mostRecentExploreItem != .search {
mostRecentExploreItem != .explore {
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
// Pop back to root, so we're appending to the Explore VC instead of some other VC
exploreNav.popToRootViewController(animated: false)
@ -188,7 +188,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab)
case .search:
case .explore:
// Search sidebar item maps to the Explore tab with the search controller/results visible
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
// so that explore items aren't shown multiple times.
@ -217,11 +217,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
explore.resultsController.loadResults(from: search.resultsController)
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .search, to: exploreNav, dropFirst: true, append: true)
transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true)
tabBarViewController.select(tab: .explore)
case .bookmarks, .trendingStatuses, .trendingTags, .trendingLinks, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
case .bookmarks, .trendingStatuses, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show it's search bar when it appears, in case the user was previously
// in compact mode and performing a search.
@ -272,7 +272,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
// Search screen has special considerations, all others can be transferred directly.
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
exploreItem = .search
exploreItem = .explore
let searchVC = SearchViewController(mastodonController: mastodonController)
searchVC.loadViewIfNeeded()
let explore = tabNavigationStack.first as! ExploreViewController
@ -300,9 +300,9 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case is TrendingStatusesViewController:
exploreItem = .trendingStatuses
case is TrendingHashtagsViewController:
exploreItem = .trendingTags
exploreItem = .explore
case is TrendingLinksViewController:
exploreItem = .trendingLinks
exploreItem = .explore
case is ProfileDirectoryViewController:
exploreItem = .profileDirectory
default:
@ -354,16 +354,12 @@ fileprivate extension MainSidebarViewController.Item {
switch self {
case let .tab(tab):
return tab.createViewController(mastodonController)
case .search:
case .explore:
return SearchViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController)
case .trendingStatuses:
return TrendingStatusesViewController(mastodonController: mastodonController)
case .trendingTags:
return TrendingHashtagsViewController(mastodonController: mastodonController)
case .trendingLinks:
return TrendingLinksViewController(mastodonController: mastodonController)
case .profileDirectory:
return ProfileDirectoryViewController(mastodonController: mastodonController)
case let .list(list):
@ -435,8 +431,8 @@ extension MainSplitViewController: TuskerRootViewController {
return
}
if sidebar.selectedItem != .search {
select(item: .search)
if sidebar.selectedItem != .explore {
select(item: .explore)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? SearchViewController else {

View File

@ -7,11 +7,16 @@
//
import UIKit
import Pachyderm
import SafariServices
class SearchViewController: UIViewController {
weak var mastodonController: MastodonController!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
@ -22,7 +27,7 @@ class SearchViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Search", comment: "search tab title")
title = NSLocalizedString("Explore", comment: "explore tab title")
}
required init?(coder: NSCoder) {
@ -32,12 +37,46 @@ class SearchViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
let sectionIdentifier = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
switch sectionIdentifier {
case .trendingHashtags:
var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
listConfig.headerMode = .supplementary
return .list(using: listConfig, layoutEnvironment: environment)
case .trendingLinks:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// todo: i really wish i could just say the height is automatic and let autolayout figure out what it needs to be
// using .estimated(whatever) constrains the height to exactly whatever
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
]
return section
default:
fatalError("unimplemented")
}
}
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.backgroundColor = .secondarySystemBackground
view.addSubview(collectionView)
dataSource = createDataSource()
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = false
searchController.obscuresBackgroundDuringPresentation = true
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.hidesNavigationBarDuringPresentation = false
@ -48,6 +87,18 @@ class SearchViewController: UIViewController {
if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil {
await applySnapshot()
}
}
}
override func viewDidAppear(_ animated: Bool) {
@ -61,5 +112,214 @@ class SearchViewController: UIViewController {
searchControllerStatusOnAppearance = nil
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
var config = UIListContentConfiguration.groupedHeader()
config.text = section.title
headerView.contentConfiguration = config
}
let trendingHashtagCell = UICollectionView.CellRegistration<TrendingHashtagCollectionViewCell, Hashtag> { (cell, indexPath, hashtag) in
cell.updateUI(hashtag: hashtag)
}
let trendingLinkCell = UICollectionView.CellRegistration<TrendingLinkCardCollectionViewCell, Card>(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in
cell.updateUI(card: card)
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch item {
case let .tag(hashtag):
return collectionView.dequeueConfiguredReusableCell(using: trendingHashtagCell, for: indexPath, item: hashtag)
case let .link(card):
return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card)
default:
fatalError("todo")
}
}
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
if elementKind == UICollectionView.elementKindSectionHeader {
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
} else {
return nil
}
}
return dataSource
}
@MainActor
private func applySnapshot() async {
guard mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover else {
await dataSource.apply(NSDiffableDataSourceSnapshot())
return
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
let hashtagsReq = Client.getTrendingHashtags(limit: 5)
async let hashtags = try? mastodonController.run(hashtagsReq).0
let linksReq = Client.getTrendingLinks(limit: 10)
async let links = try? mastodonController.run(linksReq).0
if let hashtags = await hashtags {
snapshot.appendSections([.trendingHashtags])
snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags)
}
if let links = await links {
snapshot.appendSections([.trendingLinks])
snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks)
}
await dataSource.apply(snapshot)
}
@objc private func preferencesChanged() {
Task {
await applySnapshot()
}
}
}
extension SearchViewController {
enum Section {
case trendingHashtags
case trendingLinks
case trendingStatuses
case profileSuggestions
var title: String {
switch self {
case .trendingHashtags:
return "Trending Hashtags"
case .trendingLinks:
return "Trending Links"
case .trendingStatuses:
return "Trending Statuses"
case .profileSuggestions:
return "Suggested Accounts"
}
}
}
enum Item: Equatable, Hashable {
case status(String)
case tag(Hashtag)
case link(Card)
static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool {
switch (lhs, rhs) {
case let (.status(a), .status(b)):
return a == b
case let (.tag(a), .tag(b)):
return a == b
case let (.link(a), .link(b)):
return a.url == b.url
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id):
hasher.combine("status")
hasher.combine(id)
case let .tag(tag):
hasher.combine("tag")
hasher.combine(tag.name)
case let .link(card):
hasher.combine("link")
hasher.combine(card.url)
}
}
}
}
extension SearchViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return
}
switch item {
case let .tag(hashtag):
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case let .link(card):
if let url = URL(card.url) {
selected(url: url)
}
default:
fatalError("todo")
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
switch item {
case let .tag(hashtag):
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
}
case let .link(card):
guard let url = URL(card.url) else {
return nil
}
return UIContextMenuConfiguration {
SFSafariViewController(url: url)
} actionProvider: { _ in
UIMenu(children: self.actionsForTrendingLink(card: card))
}
default:
fatalError("todo")
}
}
}
extension SearchViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return []
}
switch item {
case let .tag(hashtag):
let provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
}
return [UIDragItem(itemProvider: provider)]
case let .link(card):
guard let url = URL(card.url) else {
return []
}
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
default:
fatalError("todo")
}
}
}
extension SearchViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension SearchViewController: ToastableViewController {
}
extension SearchViewController: MenuActionProvider {
}