Explore instance section and profile directory

This commit is contained in:
Justin Mazzocchi 2021-01-30 21:43:40 -08:00
parent 5f03ce7d8b
commit e62e87510d
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
14 changed files with 296 additions and 45 deletions

View file

@ -8,13 +8,35 @@ import ViewModels
final class ExploreDataSource: UICollectionViewDiffableDataSource<ExploreViewModel.Section, ExploreViewModel.Item> {
private let updateQueue =
DispatchQueue(label: "com.metabolist.metatext.explore-data-source.update-queue")
private weak var collectionView: UICollectionView?
private var cancellables = Set<AnyCancellable>()
init(collectionView: UICollectionView, viewModel: ExploreViewModel) {
self.collectionView = collectionView
let tagRegistration = UICollectionView.CellRegistration<TagCollectionViewCell, TagViewModel> {
$0.viewModel = $2
}
let instanceRegistration = UICollectionView.CellRegistration<InstanceCollectionViewCell, InstanceViewModel> {
$0.viewModel = $2
}
let itemRegistration = UICollectionView.CellRegistration
<SeparatorConfiguredCollectionViewListCell, ExploreViewModel.Item> {
var configuration = $0.defaultContentConfiguration()
switch $2 {
case .profileDirectory:
configuration.text = NSLocalizedString("explore.profile-directory", comment: "")
configuration.image = UIImage(systemName: "person.crop.square.fill.and.at.rectangle")
default:
break
}
$0.contentConfiguration = configuration
$0.accessories = [.disclosureIndicator()]
}
super.init(collectionView: collectionView) {
switch $2 {
case let .tag(tag):
@ -22,6 +44,13 @@ final class ExploreDataSource: UICollectionViewDiffableDataSource<ExploreViewMod
using: tagRegistration,
for: $1,
item: viewModel.viewModel(tag: tag))
case .instance:
return $0.dequeueConfiguredReusableCell(
using: instanceRegistration,
for: $1,
item: viewModel.instanceViewModel)
default:
return $0.dequeueConfiguredReusableCell(using: itemRegistration, for: $1, item: $2)
}
}
@ -34,18 +63,8 @@ final class ExploreDataSource: UICollectionViewDiffableDataSource<ExploreViewMod
$0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2)
}
viewModel.$trends.sink { [weak self] tags in
guard let self = self else { return }
var snapshot = NSDiffableDataSourceSnapshot<ExploreViewModel.Section, ExploreViewModel.Item>()
if !tags.isEmpty {
snapshot.appendSections([.trending])
snapshot.appendItems(tags.map(ExploreViewModel.Item.tag), toSection: .trending)
}
self.apply(snapshot, animatingDifferences: false)
}
viewModel.$trends.combineLatest(viewModel.$instanceViewModel)
.sink { [weak self] in self?.update(tags: $0, instanceViewModel: $1) }
.store(in: &cancellables)
}
@ -58,6 +77,35 @@ final class ExploreDataSource: UICollectionViewDiffableDataSource<ExploreViewMod
}
}
private extension ExploreDataSource {
func update(tags: [Tag], instanceViewModel: InstanceViewModel?) {
var snapshot = NSDiffableDataSourceSnapshot<ExploreViewModel.Section, ExploreViewModel.Item>()
if !tags.isEmpty {
snapshot.appendSections([.trending])
snapshot.appendItems(tags.map(ExploreViewModel.Item.tag), toSection: .trending)
}
if let instanceViewModel = instanceViewModel {
snapshot.appendSections([.instance])
snapshot.appendItems([.instance], toSection: .instance)
if instanceViewModel.instance.canShowProfileDirectory {
snapshot.appendItems([.profileDirectory], toSection: .instance)
}
}
let wasEmpty = self.snapshot().itemIdentifiers.isEmpty
let contentOffset = collectionView?.contentOffset
apply(snapshot, animatingDifferences: false) {
if let contentOffset = contentOffset, !wasEmpty {
self.collectionView?.contentOffset = contentOffset
}
}
}
}
private extension ExploreViewModel.Section {
var displayName: String {
switch self {

View file

@ -79,6 +79,7 @@
"emoji.system-group.flags" = "Flags";
"explore.trending" = "Trending Now";
"explore.instance" = "Instance";
"explore.profile-directory" = "Profile Directory";
"error" = "Error";
"favorites" = "Favorites";
"follow-requests" = "Follow Requests";

View file

@ -53,3 +53,33 @@ public struct Instance: Codable, Hashable {
self.maxTootChars = maxTootChars
}
}
public extension Instance {
var majorVersion: Int? {
guard let majorVersionString = version.split(separator: ".").first else { return nil }
return Int(majorVersionString)
}
var minorVersion: Int? {
let versionComponents = version.split(separator: ".")
guard versionComponents.count > 1 else { return nil }
return Int(versionComponents[1])
}
var patchVersion: String? {
let versionComponents = version.split(separator: ".")
guard versionComponents.count > 2 else { return nil }
return String(versionComponents[2])
}
var canShowProfileDirectory: Bool {
guard let majorVersion = majorVersion else { return false }
return majorVersion >= 3
}
}

View file

@ -12,6 +12,7 @@ public enum AccountsEndpoint {
case accountsFollowers(id: Account.Id)
case accountsFollowing(id: Account.Id)
case followRequests
case directory(local: Bool)
}
extension AccountsEndpoint: Endpoint {
@ -21,7 +22,7 @@ extension AccountsEndpoint: Endpoint {
switch self {
case .rebloggedBy, .favouritedBy:
return defaultContext + ["statuses"]
case .mutes, .blocks, .followRequests:
case .mutes, .blocks, .followRequests, .directory:
return defaultContext
case .accountsFollowers, .accountsFollowing:
return defaultContext + ["accounts"]
@ -44,6 +45,17 @@ extension AccountsEndpoint: Endpoint {
return [id, "following"]
case .followRequests:
return ["follow_requests"]
case .directory:
return ["directory"]
}
}
public var queryParameters: [URLQueryItem] {
switch self {
case let .directory(local):
return [.init(name: "local", value: String(local))]
default:
return []
}
}

View file

@ -111,6 +111,10 @@
D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
D097F41B25BE3E1A00859F2C /* SearchScope+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D097F41A25BE3E1A00859F2C /* SearchScope+Extensions.swift */; };
D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D097F4C025BFA04C00859F2C /* NotificationsViewController.swift */; };
D09D970825C64522007E6394 /* InstanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D970725C64522007E6394 /* InstanceView.swift */; };
D09D970E25C64539007E6394 /* InstanceContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D970D25C64539007E6394 /* InstanceContentConfiguration.swift */; };
D09D971825C64682007E6394 /* InstanceCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */; };
D09D972225C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
@ -293,6 +297,10 @@
D08E52ED257D757100FA2C5F /* CompositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionView.swift; sourceTree = "<group>"; };
D097F41A25BE3E1A00859F2C /* SearchScope+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchScope+Extensions.swift"; sourceTree = "<group>"; };
D097F4C025BFA04C00859F2C /* NotificationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewController.swift; sourceTree = "<group>"; };
D09D970725C64522007E6394 /* InstanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceView.swift; sourceTree = "<group>"; };
D09D970D25C64539007E6394 /* InstanceContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceContentConfiguration.swift; sourceTree = "<group>"; };
D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceCollectionViewCell.swift; sourceTree = "<group>"; };
D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorConfiguredCollectionViewListCell.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
@ -492,6 +500,8 @@
isa = PBXGroup;
children = (
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */,
D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */,
D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */,
D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */,
);
path = "Collection View Cells";
@ -505,6 +515,7 @@
D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */,
D07EC7F125B13E57006DF726 /* EmojiView.swift */,
D021A61925C36C1A008A0C0D /* IdentityContentConfiguration.swift */,
D09D970D25C64539007E6394 /* InstanceContentConfiguration.swift */,
D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */,
D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */,
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */,
@ -519,6 +530,7 @@
D0F0B10D251A868200942152 /* AccountView.swift */,
D00702302555F4AE00F38136 /* ConversationView.swift */,
D021A61325C36BFB008A0C0D /* IdentityView.swift */,
D09D970725C64522007E6394 /* InstanceView.swift */,
D0E569DA2529319100FA1D72 /* LoadMoreView.swift */,
D036AA06254B6118009094DF /* NotificationView.swift */,
D00CB2EC2533ACC00080096B /* StatusView.swift */,
@ -961,6 +973,7 @@
D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
D05E688525B55AE8001FB2C6 /* AVURLAsset+Extensions.swift in Sources */,
D09D970E25C64539007E6394 /* InstanceContentConfiguration.swift in Sources */,
D036AA02254B6101009094DF /* NotificationTableViewCell.swift in Sources */,
D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */,
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
@ -989,6 +1002,7 @@
D07EC7F225B13E57006DF726 /* EmojiView.swift in Sources */,
D05936CF25A8D79800754FDF /* EditAttachmentViewController.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D09D970825C64522007E6394 /* InstanceView.swift in Sources */,
D021A62C25C38570008A0C0D /* AboutView.swift in Sources */,
D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
@ -1043,9 +1057,11 @@
D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */,
D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */,
D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */,
D09D972225C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift in Sources */,
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */,
D07EC7FD25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */,
D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */,
D09D971825C64682007E6394 /* InstanceCollectionViewCell.swift in Sources */,
D0D2AC3925BBEC0F003D5DF2 /* CollectionSection+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -35,6 +35,7 @@ final class ExploreViewController: UICollectionViewController {
collectionView.dataSource = dataSource
collectionView.backgroundColor = .systemBackground
collectionView.contentInset.bottom = Self.bottomInset
clearsSelectionOnViewWillAppear = true
collectionView.refreshControl = UIRefreshControl()
@ -92,6 +93,11 @@ final class ExploreViewController: UICollectionViewController {
viewModel.refresh()
}
override func collectionView(_ collectionView: UICollectionView,
shouldHighlightItemAt indexPath: IndexPath) -> Bool {
dataSource.itemIdentifier(for: indexPath) != .instance
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
@ -115,12 +121,23 @@ extension ExploreViewController: UISearchResultsUpdating {
}
private extension ExploreViewController {
static let bottomInset: CGFloat = .newStatusButtonDimension + .defaultSpacing * 4
static func layout() -> UICollectionViewLayout {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
var listConfiguration = UICollectionLayoutListConfiguration(appearance: .plain)
config.headerMode = .supplementary
listConfiguration.headerMode = .supplementary
return UICollectionViewCompositionalLayout.list(using: config)
return UICollectionViewCompositionalLayout(
sectionProvider: {
let section = NSCollectionLayoutSection.list(using: listConfiguration, layoutEnvironment: $1)
if UIDevice.current.userInterfaceIdiom == .pad {
section.contentInsetsReference = .readableContent
}
return section
})
}
func handle(event: ExploreViewModel.Event) {

View file

@ -49,6 +49,8 @@ public extension ExploreViewModel {
enum Item: Hashable {
case tag(Tag)
case instance
case profileDirectory
}
func refresh() {
@ -78,6 +80,14 @@ public extension ExploreViewModel {
.navigation(.collection(exploreService
.navigationService
.timelineService(timeline: .tag(tag.name)))))
case .instance:
break
case .profileDirectory:
eventsSubject.send(
.navigation(.collection(identityContext
.service
.service(accountList: .directory(local: true),
titleComponents: ["explore.profile-directory"]))))
}
}
}

View file

@ -1,6 +1,7 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Foundation
import Mastodon
import ServiceLayer
public final class InstanceViewModel: ObservableObject {
@ -10,3 +11,7 @@ public final class InstanceViewModel: ObservableObject {
self.instanceService = instanceService
}
}
public extension InstanceViewModel {
var instance: Instance { instanceService.instance }
}

View file

@ -0,0 +1,15 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class InstanceCollectionViewCell: SeparatorConfiguredCollectionViewListCell {
var viewModel: InstanceViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let viewModel = viewModel else { return }
contentConfiguration = InstanceContentConfiguration(viewModel: viewModel).updated(for: state)
updateConstraintsIfNeeded()
}
}

View file

@ -0,0 +1,14 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
class SeparatorConfiguredCollectionViewListCell: UICollectionViewListCell {
override func updateConstraints() {
super.updateConstraints()
NSLayoutConstraint.activate([
separatorLayoutGuide.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
separatorLayoutGuide.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor)
])
}
}

View file

@ -3,7 +3,7 @@
import UIKit
import ViewModels
final class TagCollectionViewCell: UICollectionViewListCell {
final class TagCollectionViewCell: SeparatorConfiguredCollectionViewListCell {
var viewModel: TagViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
@ -12,24 +12,4 @@ final class TagCollectionViewCell: UICollectionViewListCell {
contentConfiguration = TagContentConfiguration(viewModel: viewModel).updated(for: state)
updateConstraintsIfNeeded()
}
override func updateConstraints() {
super.updateConstraints()
let separatorLeadingAnchor: NSLayoutXAxisAnchor
let separatorTrailingAnchor: NSLayoutXAxisAnchor
if UIDevice.current.userInterfaceIdiom == .pad {
separatorLeadingAnchor = readableContentGuide.leadingAnchor
separatorTrailingAnchor = readableContentGuide.trailingAnchor
} else {
separatorLeadingAnchor = leadingAnchor
separatorTrailingAnchor = trailingAnchor
}
NSLayoutConstraint.activate([
separatorLayoutGuide.leadingAnchor.constraint(equalTo: separatorLeadingAnchor),
separatorLayoutGuide.trailingAnchor.constraint(equalTo: separatorTrailingAnchor)
])
}
}

View file

@ -0,0 +1,18 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
struct InstanceContentConfiguration {
let viewModel: InstanceViewModel
}
extension InstanceContentConfiguration: UIContentConfiguration {
func makeContentView() -> UIView & UIContentView {
InstanceView(configuration: self)
}
func updated(for state: UIConfigurationState) -> InstanceContentConfiguration {
self
}
}

View file

@ -0,0 +1,84 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class InstanceView: UIView {
private let imageView = AnimatedImageView()
private let titleLabel = UILabel()
private let uriLabel = UILabel()
private var instanceConfiguration: InstanceContentConfiguration
init(configuration: InstanceContentConfiguration) {
instanceConfiguration = configuration
super.init(frame: .zero)
initialSetup()
applyInstanceConfiguration()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension InstanceView: UIContentView {
var configuration: UIContentConfiguration {
get { instanceConfiguration }
set {
guard let instanceConfiguration = newValue as? InstanceContentConfiguration else { return }
self.instanceConfiguration = instanceConfiguration
applyInstanceConfiguration()
}
}
}
private extension InstanceView {
func initialSetup() {
let stackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = .defaultSpacing
stackView.addArrangedSubview(imageView)
imageView.layer.cornerRadius = .defaultCornerRadius
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
stackView.addArrangedSubview(titleLabel)
titleLabel.adjustsFontSizeToFitWidth = true
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 0
titleLabel.textAlignment = .center
stackView.addArrangedSubview(uriLabel)
uriLabel.adjustsFontSizeToFitWidth = true
uriLabel.font = .preferredFont(forTextStyle: .subheadline)
uriLabel.numberOfLines = 0
uriLabel.textAlignment = .center
uriLabel.textColor = .secondaryLabel
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: 16 / 9)
])
}
func applyInstanceConfiguration() {
let viewModel = instanceConfiguration.viewModel
imageView.kf.setImage(with: viewModel.instance.thumbnail)
titleLabel.text = viewModel.instance.title
uriLabel.text = viewModel.instance.uri
}
}

View file

@ -25,20 +25,21 @@ private extension ExploreSectionHeaderView {
label.translatesAutoresizingMaskIntoConstraints = false
label.adjustsFontForContentSizeCategory = true
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .secondaryLabel
let layoutGuide: UILayoutGuide
let leadingConstraint: NSLayoutConstraint
if UIDevice.current.userInterfaceIdiom == .pad {
layoutGuide = readableContentGuide
leadingConstraint = label.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1)
} else {
layoutGuide = layoutMarginsGuide
leadingConstraint = label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor)
}
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
label.topAnchor.constraint(equalTo: layoutGuide.topAnchor),
label.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
label.bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor)
leadingConstraint,
label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
}
}