forked from zelo72/mastodon-ios
feat: add Discovery page with posts segment
This commit is contained in:
parent
8a051c2177
commit
af619e198a
|
@ -492,6 +492,14 @@
|
|||
"clear": "Clear"
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"tabs": {
|
||||
"posts": "Posts",
|
||||
"hashtags": "Hashtags",
|
||||
"news": "News",
|
||||
"for_you": "For You"
|
||||
}
|
||||
},
|
||||
"favorite": {
|
||||
"title": "Your Favorites"
|
||||
},
|
||||
|
|
|
@ -550,6 +550,13 @@
|
|||
DBD5B1F827BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */; };
|
||||
DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */; };
|
||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
|
||||
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */; };
|
||||
DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */; };
|
||||
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */; };
|
||||
DBDFF197280556D900557A48 /* DiscoveryPostsViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */; };
|
||||
DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */; };
|
||||
DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */; };
|
||||
DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */; };
|
||||
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
|
||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
|
||||
DBE3CA6827A39CAB00AFE27B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; };
|
||||
|
@ -1293,6 +1300,13 @@
|
|||
DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+TableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
|
||||
DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
|
||||
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
|
||||
DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewController.swift; sourceTree = "<group>"; };
|
||||
DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewModel.swift; sourceTree = "<group>"; };
|
||||
DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewController.swift; sourceTree = "<group>"; };
|
||||
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModel.swift; sourceTree = "<group>"; };
|
||||
DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
|
||||
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
|
||||
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
|
||||
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -2682,6 +2696,7 @@
|
|||
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */,
|
||||
DB9D6C0825E4F5A60051B173 /* Profile */,
|
||||
DB9D6BEE25E4F5370051B173 /* Search */,
|
||||
DBDFF1912805544800557A48 /* Discovery */,
|
||||
5B90C455262599800002E742 /* Settings */,
|
||||
);
|
||||
path = Scene;
|
||||
|
@ -3068,6 +3083,28 @@
|
|||
path = FetchedResultsController;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBDFF1912805544800557A48 /* Discovery */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBDFF19828055A0900557A48 /* Posts */,
|
||||
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */,
|
||||
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */,
|
||||
);
|
||||
path = Discovery;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBDFF19828055A0900557A48 /* Posts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */,
|
||||
DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */,
|
||||
DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */,
|
||||
DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */,
|
||||
DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */,
|
||||
);
|
||||
path = Posts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBE0821A25CD382900FD6BBD /* Register */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3797,6 +3834,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
|
||||
DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */,
|
||||
DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */,
|
||||
DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */,
|
||||
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
|
||||
|
@ -3858,6 +3896,7 @@
|
|||
DB697DD6278F4C29004EF2F7 /* DataSourceProvider.swift in Sources */,
|
||||
DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */,
|
||||
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
|
||||
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */,
|
||||
DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */,
|
||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
||||
|
@ -3878,7 +3917,9 @@
|
|||
DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */,
|
||||
DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */,
|
||||
2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */,
|
||||
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */,
|
||||
DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */,
|
||||
DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */,
|
||||
DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */,
|
||||
DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */,
|
||||
DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */,
|
||||
|
@ -4012,6 +4053,7 @@
|
|||
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
|
||||
DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */,
|
||||
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
|
||||
DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */,
|
||||
2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */,
|
||||
DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */,
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
|
||||
|
@ -4069,6 +4111,7 @@
|
|||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */,
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||
DBDFF197280556D900557A48 /* DiscoveryPostsViewModel+State.swift in Sources */,
|
||||
DB336F2C278D6FC30031E64B /* Persistence+Status.swift in Sources */,
|
||||
DB336F2A278D6F2B0031E64B /* MastodonField.swift in Sources */,
|
||||
DB0FCB7A279576A2006C02E2 /* DataSourceFacade+Thread.swift in Sources */,
|
||||
|
@ -4246,6 +4289,7 @@
|
|||
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */,
|
||||
DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */,
|
||||
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
|
||||
DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */,
|
||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||
DB63F756279949BD00455B82 /* Persistence+SearchHistory.swift in Sources */,
|
||||
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
|
||||
|
|
|
@ -109,7 +109,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>24</integer>
|
||||
<integer>23</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -124,12 +124,12 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>22</integer>
|
||||
<integer>24</integer>
|
||||
</dict>
|
||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>23</integer>
|
||||
<integer>22</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
//
|
||||
// DiscoveryViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Tabman
|
||||
import MastodonAsset
|
||||
|
||||
public class DiscoveryViewController: TabmanViewController, NeedsDependency {
|
||||
|
||||
public static let containerViewMarginForRegularHorizontalSizeClass: CGFloat = 64
|
||||
public static let containerViewMarginForCompactHorizontalSizeClass: CGFloat = 16
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let logger = Logger(subsystem: "DiscoveryViewController", category: "ViewController")
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
private(set) lazy var viewModel = DiscoveryViewModel(
|
||||
context: context,
|
||||
coordinator: coordinator
|
||||
)
|
||||
|
||||
let buttonBar: TMBar.ButtonBar = {
|
||||
let buttonBar = TMBar.ButtonBar()
|
||||
buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color
|
||||
buttonBar.layout.contentInset = .zero
|
||||
return buttonBar
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryViewController {
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupAppearance(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupAppearance(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
dataSource = viewModel
|
||||
addBar(
|
||||
buttonBar,
|
||||
dataSource: viewModel,
|
||||
at: .top
|
||||
)
|
||||
updateBarButtonInsets()
|
||||
}
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
updateBarButtonInsets()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryViewController {
|
||||
|
||||
private func setupAppearance(theme: Theme) {
|
||||
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
|
||||
buttonBar.backgroundView.style = .flat(color: theme.systemBackgroundColor)
|
||||
}
|
||||
|
||||
private func updateBarButtonInsets() {
|
||||
let margin: CGFloat = {
|
||||
switch traitCollection.userInterfaceIdiom {
|
||||
case .phone:
|
||||
return DiscoveryViewController.containerViewMarginForCompactHorizontalSizeClass
|
||||
default:
|
||||
return traitCollection.horizontalSizeClass == .regular ?
|
||||
DiscoveryViewController.containerViewMarginForRegularHorizontalSizeClass :
|
||||
DiscoveryViewController.containerViewMarginForCompactHorizontalSizeClass
|
||||
}
|
||||
}()
|
||||
|
||||
buttonBar.layout.contentInset.left = margin
|
||||
buttonBar.layout.contentInset.right = margin
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// DiscoveryViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Tabman
|
||||
import Pageboy
|
||||
|
||||
final class DiscoveryViewModel {
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let discoveryViewController: DiscoveryPostsViewController
|
||||
|
||||
// output
|
||||
let barItems: [TMBarItemable] = {
|
||||
let items = [
|
||||
TMBarItem(title: "Posts"),
|
||||
TMBarItem(title: "Hashtags"),
|
||||
TMBarItem(title: "News"),
|
||||
TMBarItem(title: "For You"),
|
||||
]
|
||||
return items
|
||||
}()
|
||||
|
||||
var viewControllers: [ScrollViewContainer] {
|
||||
return [
|
||||
discoveryViewController,
|
||||
]
|
||||
}
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator) {
|
||||
self.context = context
|
||||
discoveryViewController = {
|
||||
let viewController = DiscoveryPostsViewController()
|
||||
viewController.context = context
|
||||
viewController.coordinator = coordinator
|
||||
viewController.viewModel = DiscoveryPostsViewModel(context: context)
|
||||
return viewController
|
||||
}()
|
||||
// end init
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - PageboyViewControllerDataSource
|
||||
extension DiscoveryViewModel: PageboyViewControllerDataSource {
|
||||
|
||||
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
|
||||
return viewControllers.count
|
||||
}
|
||||
|
||||
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
|
||||
return viewControllers[index]
|
||||
}
|
||||
|
||||
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
|
||||
return .first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TMBarDataSource
|
||||
extension DiscoveryViewModel: TMBarDataSource {
|
||||
func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
|
||||
return barItems[index]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// DiscoveryPostsViewController+DataSourceProvider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension DiscoveryPostsViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
var _indexPath = source.indexPath
|
||||
if _indexPath == nil, let cell = source.tableViewCell {
|
||||
_indexPath = await self.indexPath(for: cell)
|
||||
}
|
||||
guard let indexPath = _indexPath else { return nil }
|
||||
|
||||
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .status(let record):
|
||||
return .status(record: record)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
//
|
||||
// DiscoveryPostsViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class DiscoveryPostsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController")
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: DiscoveryPostsViewModel!
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 100
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .clear
|
||||
return tableView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryPostsViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.view.backgroundColor = theme.secondarySystemBackgroundColor
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
tableView.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
tableView: tableView,
|
||||
statusTableViewCellDelegate: self
|
||||
)
|
||||
|
||||
// setup batch fetch
|
||||
viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
|
||||
viewModel.listBatchFetchViewModel.shouldFetch
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
guard self.view.window != nil else { return }
|
||||
self.viewModel.stateMachine.enter(DiscoveryPostsViewModel.State.Loading.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension DiscoveryPostsViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
|
||||
// sourcery:inline:DiscoveryPostsViewController.AutoGenerateTableViewDelegate
|
||||
|
||||
// Generated using Sourcery
|
||||
// DO NOT EDIT
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
// sourcery:end
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewCellDelegate
|
||||
extension DiscoveryPostsViewController: StatusTableViewCellDelegate { }
|
||||
|
||||
// MARK: ScrollViewContainer
|
||||
extension DiscoveryPostsViewController: ScrollViewContainer {
|
||||
var scrollView: UIScrollView? {
|
||||
tableView
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// DiscoveryPostsViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
extension DiscoveryPostsViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) {
|
||||
diffableDataSource = StatusSection.diffableDataSource(
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
filterContext: .none,
|
||||
activeFilters: nil
|
||||
)
|
||||
)
|
||||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
let items = records.map { StatusItem.status(record: $0) }
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Initial,
|
||||
is State.Reloading,
|
||||
is State.Loading,
|
||||
is State.Idle,
|
||||
is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
default:
|
||||
assertionFailure()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diffableDataSource.applySnapshot(snapshot, animated: false)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
//
|
||||
// DiscoveryPostsViewModel+State.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension DiscoveryPostsViewModel {
|
||||
class State: GKState, NamingState {
|
||||
|
||||
let logger = Logger(subsystem: "TrendPostsViewModel.State", category: "StateMachine")
|
||||
|
||||
let id = UUID()
|
||||
|
||||
var name: String {
|
||||
String(describing: Self.self)
|
||||
}
|
||||
|
||||
weak var viewModel: DiscoveryPostsViewModel?
|
||||
|
||||
init(viewModel: DiscoveryPostsViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
let previousState = previousState as? DiscoveryPostsViewModel.State
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func enter(state: State.Type) {
|
||||
stateMachine?.enter(state)
|
||||
}
|
||||
|
||||
deinit {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DiscoveryPostsViewModel.State {
|
||||
class Initial: DiscoveryPostsViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Reloading: DiscoveryPostsViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
// reset
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = []
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: DiscoveryPostsViewModel.State {
|
||||
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: DiscoveryPostsViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type, is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: DiscoveryPostsViewModel.State {
|
||||
|
||||
var offset: Int?
|
||||
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Fail.Type:
|
||||
return true
|
||||
case is Idle.Type:
|
||||
return true
|
||||
case is NoMore.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
|
||||
|
||||
switch previousState {
|
||||
case is Reloading:
|
||||
offset = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let offset = self.offset
|
||||
|
||||
Task {
|
||||
do {
|
||||
let response = try await viewModel.context.apiService.trendStatuses(
|
||||
domain: authenticationBox.domain,
|
||||
query: Mastodon.API.Trends.StatusQuery(
|
||||
offset: offset,
|
||||
limit: nil
|
||||
)
|
||||
)
|
||||
let newOffset: Int? = {
|
||||
guard let offset = response.link?.offset else { return nil }
|
||||
return self.offset.flatMap { max($0, offset) } ?? offset
|
||||
}()
|
||||
|
||||
let hasMore: Bool = {
|
||||
guard let newOffset = newOffset else { return false }
|
||||
return newOffset != self.offset // not the same one
|
||||
}()
|
||||
|
||||
self.offset = newOffset
|
||||
|
||||
var hasNewStatusesAppend = false
|
||||
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
if hasNewStatusesAppend, hasMore {
|
||||
await enter(state: Idle.self)
|
||||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
|
||||
|
||||
} catch {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)")
|
||||
await enter(state: Fail.self)
|
||||
}
|
||||
} // end Task
|
||||
} // end func
|
||||
}
|
||||
|
||||
class NoMore: DiscoveryPostsViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// DiscoveryPostsViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class DiscoveryPostsViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
|
||||
private(set) lazy var stateMachine: GKStateMachine = {
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
State.Initial(viewModel: self),
|
||||
State.Reloading(viewModel: self),
|
||||
State.Fail(viewModel: self),
|
||||
State.Idle(viewModel: self),
|
||||
State.Loading(viewModel: self),
|
||||
State.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(State.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
// end init
|
||||
|
||||
context.authenticationService.activeMastodonAuthentication
|
||||
.map { $0?.domain }
|
||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
|
@ -402,6 +402,7 @@ extension ProfileViewController {
|
|||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
|
||||
private func updateBarButtonInsets() {
|
||||
let margin: CGFloat = {
|
||||
switch traitCollection.userInterfaceIdiom {
|
||||
|
|
|
@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media
|
|||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -48,6 +48,13 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
|||
|
||||
let searchBarTapPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
private(set) lazy var trendViewController: DiscoveryViewController = {
|
||||
let viewController = DiscoveryViewController()
|
||||
viewController.context = context
|
||||
viewController.coordinator = coordinator
|
||||
return viewController
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
@ -84,6 +91,16 @@ extension SearchViewController {
|
|||
viewModel.setupDiffableDataSource(
|
||||
collectionView: collectionView
|
||||
)
|
||||
|
||||
addChild(trendViewController)
|
||||
trendViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(trendViewController.view)
|
||||
NSLayoutConstraint.activate([
|
||||
trendViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
trendViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
trendViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
trendViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
|
|
@ -38,7 +38,7 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
.throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
|
||||
.asyncMap { authenticationBox in
|
||||
try await context.apiService.trends(domain: authenticationBox.domain, query: nil)
|
||||
try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
|
||||
}
|
||||
.retry(3)
|
||||
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
|
|
|
@ -9,11 +9,12 @@ import Foundation
|
|||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
func trends(
|
||||
|
||||
func trendHashtags(
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.Query?
|
||||
query: Mastodon.API.Trends.HashtagQuery?
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> {
|
||||
let response = try await Mastodon.API.Trends.get(
|
||||
let response = try await Mastodon.API.Trends.hashtags(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query
|
||||
|
@ -21,4 +22,35 @@ extension APIService {
|
|||
|
||||
return response
|
||||
}
|
||||
|
||||
func trendStatuses(
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.StatusQuery
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> {
|
||||
let response = try await Mastodon.API.Trends.statuses(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query
|
||||
).singleOutput()
|
||||
|
||||
let managedObjectContext = backgroundManagedObjectContext
|
||||
try await managedObjectContext.performChanges {
|
||||
for entity in response.value {
|
||||
_ = Persistence.Status.createOrMerge(
|
||||
in: managedObjectContext,
|
||||
context: Persistence.Status.PersistContext(
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
me: nil,
|
||||
statusCache: nil,
|
||||
userCache: nil,
|
||||
networkDate: response.networkDate
|
||||
)
|
||||
)
|
||||
} // end for … in
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import Combine
|
|||
import Foundation
|
||||
|
||||
extension Mastodon.API.Trends {
|
||||
|
||||
static func trendsURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("trends")
|
||||
}
|
||||
|
@ -27,10 +28,10 @@ extension Mastodon.API.Trends {
|
|||
/// - query: query
|
||||
/// - Returns: `AnyPublisher` contains `Hashtags` nested in the response
|
||||
|
||||
public static func get(
|
||||
public static func hashtags(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.Query?
|
||||
query: Mastodon.API.Trends.HashtagQuery?
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: trendsURL(domain: domain),
|
||||
|
@ -44,10 +45,8 @@ extension Mastodon.API.Trends {
|
|||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.API.Trends {
|
||||
public struct Query: Codable, GetQuery {
|
||||
public struct HashtagQuery: Codable, GetQuery {
|
||||
public init(limit: Int?) {
|
||||
self.limit = limit
|
||||
}
|
||||
|
@ -61,4 +60,69 @@ extension Mastodon.API.Trends {
|
|||
return items
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Trends {
|
||||
|
||||
static func trendStatusesURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointURL(domain: domain)
|
||||
.appendingPathComponent("trends")
|
||||
.appendingPathComponent("statuses")
|
||||
}
|
||||
|
||||
/// Trending tags
|
||||
///
|
||||
/// Tags that are being used more frequently within the past week.
|
||||
///
|
||||
/// Version history:
|
||||
/// 3.?.?
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/instance/trends/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: query
|
||||
/// - Returns: `AnyPublisher` contains `Hashtags` nested in the response
|
||||
|
||||
public static func statuses(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.StatusQuery?
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: trendStatusesURL(domain: domain),
|
||||
query: query,
|
||||
authorization: nil
|
||||
)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public struct StatusQuery: Codable, GetQuery {
|
||||
|
||||
public let offset: Int?
|
||||
public let limit: Int? // Maximum number of results to return. Defaults to 10.
|
||||
|
||||
public init(
|
||||
offset: Int?,
|
||||
limit: Int?
|
||||
) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
var items: [URLQueryItem] = []
|
||||
offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) }
|
||||
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -106,6 +106,7 @@ extension Mastodon.Response {
|
|||
public struct Link {
|
||||
public let maxID: Mastodon.Entity.Status.ID?
|
||||
public let minID: Mastodon.Entity.Status.ID?
|
||||
public let offset: Int?
|
||||
|
||||
init(link: String) {
|
||||
self.maxID = {
|
||||
|
@ -125,6 +126,15 @@ extension Mastodon.Response {
|
|||
let id = link[range]
|
||||
return String(id)
|
||||
}()
|
||||
|
||||
self.offset = {
|
||||
guard let regex = try? NSRegularExpression(pattern: "offset=([[:digit:]]+)", options: []) else { return nil }
|
||||
let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..<link.endIndex, in: link))
|
||||
guard let match = results.first else { return nil }
|
||||
guard let range = Range(match.range(at: 1), in: link) else { return nil }
|
||||
let offset = link[range]
|
||||
return Int(offset)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue