feat: implement hashtag timeline

This commit is contained in:
jk234ert 2021-04-01 10:12:57 +08:00
parent 8c3040c0f9
commit d548840bd9
16 changed files with 1165 additions and 6 deletions

View File

@ -7,6 +7,15 @@
objects = {
/* Begin PBXBuildFile section */
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; };
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; };
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; };
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; };
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; };
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; };
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; };
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; };
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; };
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; };
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
@ -316,6 +325,15 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; };
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; };
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = "<group>"; };
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = "<group>"; };
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = "<group>"; };
0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
@ -634,6 +652,20 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0F2021F5261325ED000C64BF /* HashtagTimeline */ = {
isa = PBXGroup;
children = (
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */,
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */,
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */,
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */,
);
path = HashtagTimeline;
sourceTree = "<group>";
};
0FAA0FDD25E0B5700017CCDE /* Welcome */ = {
isa = PBXGroup;
children = (
@ -1119,6 +1151,7 @@
DB9A488F26035963008B817C /* APIService+Media.swift */,
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
);
path = APIService;
sourceTree = "<group>";
@ -1328,6 +1361,7 @@
DB8AF55525C1379F002E6C99 /* Scene */ = {
isa = PBXGroup;
children = (
0F2021F5261325ED000C64BF /* HashtagTimeline */,
2D7631A425C1532200929FB9 /* Share */,
DB8AF54E25C13703002E6C99 /* MainTab */,
DB01409B25C40BB600F9F3CF /* Onboarding */,
@ -1369,6 +1403,7 @@
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
2D84350425FF858100EECE90 /* UIScrollView.swift */,
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
0F20223826146553000C64BF /* Array+removeDuplicates.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -1892,6 +1927,7 @@
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
@ -1911,6 +1947,7 @@
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
@ -1941,6 +1978,7 @@
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
@ -1978,6 +2016,7 @@
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
@ -2005,6 +2044,7 @@
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
@ -2012,7 +2052,9 @@
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */,
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */,
@ -2021,10 +2063,12 @@
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,

View File

@ -50,6 +50,9 @@ extension SceneCoordinator {
// compose
case compose(viewModel: ComposeViewModel)
// Hashtag Timeline
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
// misc
case alertController(alertController: UIAlertController)
@ -206,6 +209,10 @@ private extension SceneCoordinator {
)
}
viewController = alertController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
#if DEBUG
case .publicTimeline:
let _viewController = PublicTimelineViewController()

View File

@ -0,0 +1,23 @@
//
// Array+removeDuplicates.swift
// Mastodon
//
// Created by BradGao on 2021/3/31.
//
import Foundation
/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array
extension Array where Element: Hashable {
func removingDuplicates() -> [Element] {
var addedDict = [Element: Bool]()
return filter {
addedDict.updateValue(true, forKey: $0) == nil
}
}
mutating func removeDuplicates() {
self = self.removingDuplicates()
}
}

View File

@ -192,3 +192,21 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
}
// MARK: - ActiveLabel didSelect ActiveEntity
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) {
switch entity.type {
case .hashtag(let hashtag, let userInfo):
let hashtagTimelienViewModel = HashtagTimelineViewModel(context: context, hashTag: hashtag)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: self, transition: .show)
break
case .email(let content, let userInfo):
break
case .mention(let mention, let userInfo):
break
case .url(let content, let trimmed, let url, let userInfo):
break
}
}
}

View File

@ -56,6 +56,8 @@ final class ComposeViewModel {
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let characterCount = CurrentValueSubject<Int, Never>(0)
var injectedContent: String? = nil
// custom emojis
var customEmojiViewModelSubscription: AnyCancellable?
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
@ -71,10 +73,12 @@ final class ComposeViewModel {
init(
context: AppContext,
composeKind: ComposeStatusSection.ComposeKind
composeKind: ComposeStatusSection.ComposeKind,
injectedContent: String? = nil
) {
self.context = context
self.composeKind = composeKind
self.injectedContent = injectedContent
switch composeKind {
case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
@ -195,9 +199,16 @@ final class ComposeViewModel {
// bind modal dismiss state
composeStatusAttribute.composeContent
.receive(on: DispatchQueue.main)
.map { content in
.map { [weak self] content in
let content = content ?? ""
return content.isEmpty
if content.isEmpty {
return true
}
// if injectedContent plus a space is equal to the content, simply dismiss the modal
if let injectedContent = self?.injectedContent {
return content == (injectedContent + " ")
}
return false
}
.assign(to: \.value, on: shouldDismiss)
.store(in: &disposeBag)
@ -304,6 +315,11 @@ final class ComposeViewModel {
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
})
.store(in: &disposeBag)
if let injectedContent = injectedContent {
// add a space after the injected text
composeStatusAttribute.composeContent.send(injectedContent + " ")
}
}
deinit {

View File

@ -0,0 +1,88 @@
//
// HashtagTimelineViewController+StatusProvider.swift
// Mastodon
//
// Created by BradGao on 2021/3/31.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
// MARK: - StatusProvider
extension HashtagTimelineViewController: StatusProvider {
func toot() -> Future<Toot?, Never> {
return Future { promise in promise(.success(nil)) }
}
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never> {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
}
switch item {
case .homeTimelineIndex(let objectID, _):
let managedObjectContext = self.viewModel.context.managedObjectContext
managedObjectContext.perform {
let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
promise(.success(timelineIndex?.toot))
}
default:
promise(.success(nil))
}
}
}
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never> {
return Future { promise in promise(.success(nil)) }
}
var managedObjectContext: NSManagedObjectContext {
return viewModel.context.managedObjectContext
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return viewModel.diffableDataSource
}
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return nil
}
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
return nil
}
return item
}
func items(indexPaths: [IndexPath]) -> [Item] {
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
return []
}
var items: [Item] = []
for indexPath in indexPaths {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
items.append(item)
}
return items
}
}

View File

@ -0,0 +1,279 @@
//
// HashtagTimelineViewController.swift
// Mastodon
//
// Created by BradGao on 2021/3/30.
//
import os.log
import UIKit
import AVKit
import Combine
import GameplayKit
import CoreData
class HashtagTimelineViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: HashtagTimelineViewModel!
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
let refreshControl = UIRefreshControl()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension HashtagTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "#\(viewModel.hashTag)"
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
navigationItem.rightBarButtonItem = composeBarButtonItem
composeBarButtonItem.target = self
composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:))
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
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),
])
viewModel.tableView = tableView
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
tableView.delegate = self
tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
statusTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
)
// bind refresh control
viewModel.isFetchingLatestTimeline
.receive(on: DispatchQueue.main)
.sink { [weak self] isFetching in
guard let self = self else { return }
if !isFetching {
UIView.animate(withDuration: 0.5) { [weak self] in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
}
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return }
tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true)
refreshControl.beginRefreshing()
refreshControl.sendActions(for: .valueChanged)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
context.videoPlaybackService.viewDidDisappear(from: self)
context.audioPlaybackService.viewDidDisappear(from: self)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in
// do nothing
} completion: { _ in
// fix AutoLayout cell height not update after rotate issue
self.viewModel.cellFrameCache.removeAllObjects()
self.tableView.reloadData()
}
}
}
extension HashtagTimelineViewController {
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)")
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else {
sender.endRefreshing()
return
}
}
}
// MARK: - UIScrollViewDelegate
extension HashtagTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
}
}
extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
}
// MARK: - UITableViewDelegate
extension HashtagTimelineViewController: UITableViewDelegate {
// TODO:
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
//
// guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
// return 200
// }
// // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
//
// return ceil(frame.height)
// }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
}
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
func navigationBar() -> UINavigationBar? {
return navigationController?.navigationBar
}
}
// MARK: - UITableViewDataSourcePrefetching
extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
handleTableView(tableView, prefetchRowsAt: indexPaths)
}
}
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) {
guard let upperTimelineIndexObjectID = timelineIndexobjectID else {
return
}
viewModel.loadMiddleSateMachineList
.receive(on: DispatchQueue.main)
.sink { [weak self] ids in
guard let _ = self else { return }
if let stateMachine = ids[upperTimelineIndexObjectID] {
guard let state = stateMachine.currentState else {
assertionFailure()
return
}
// make success state same as loading due to snapshot updating delay
let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success
if isLoading {
cell.startAnimating()
} else {
cell.stopAnimating()
}
} else {
cell.stopAnimating()
}
}
.store(in: &cell.disposeBag)
var dict = viewModel.loadMiddleSateMachineList.value
if let _ = dict[upperTimelineIndexObjectID] {
// do nothing
} else {
let stateMachine = GKStateMachine(states: [
HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
])
stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self)
dict[upperTimelineIndexObjectID] = stateMachine
viewModel.loadMiddleSateMachineList.value = dict
}
}
func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .homeMiddleLoader(let upper):
guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else {
assertionFailure()
return
}
stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self)
default:
assertionFailure()
}
}
}
// MARK: - AVPlayerViewControllerDelegate
extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
}
}
// MARK: - StatusTableViewCellDelegate
extension HashtagTimelineViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}

View File

@ -0,0 +1,128 @@
//
// HashtagTimelineViewModel+Diffable.swift
// Mastodon
//
// Created by BradGao on 2021/3/30.
//
import os.log
import UIKit
import CoreData
import CoreDataStack
extension HashtagTimelineViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: context.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let tableView = self.tableView else { return }
guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
let parentManagedObjectContext = fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
let oldSnapshot = diffableDataSource.snapshot()
let snapshot = snapshot as NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>
let statusItemList: [Item] = snapshot.itemIdentifiers.map {
let status = managedObjectContext.object(with: $0) as! Toot
let isStatusTextSensitive: Bool = {
guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive))
}
var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
newSnapshot.appendSections([.main])
// Check if there is a `needLoadMiddleIndex`
if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) {
// If yes, insert a `middleLoader` at the index
var newItems = statusItemList
newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1))
newSnapshot.appendItems(newItems, toSection: .main)
} else {
newSnapshot.appendItems(statusItemList, toSection: .main)
}
if !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) {
newSnapshot.appendItems([.bottomLoader], toSection: .main)
}
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
diffableDataSource.apply(newSnapshot)
self.isFetchingLatestTimeline.value = false
return
}
DispatchQueue.main.async {
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
self.isFetchingLatestTimeline.value = false
}
}
}
private struct Difference<T> {
let targetIndexPath: IndexPath
let offset: CGFloat
}
private func calculateReloadSnapshotDifference<T: Hashable>(
navigationBar: UINavigationBar,
tableView: UITableView,
oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>,
newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>
) -> Difference<T>? {
guard oldSnapshot.numberOfItems != 0 else { return nil }
let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first!
guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil }
if oldItemBeginIndexInNewSnapshot > 0 {
let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0)
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar)
return Difference(
targetIndexPath: targetIndexPath,
offset: offset
)
}
return nil
}
}

View File

@ -0,0 +1,104 @@
//
// HashtagTimelineViewModel+LoadLatestState.swift
// Mastodon
//
// Created by BradGao on 2021/3/30.
//
import os.log
import UIKit
import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
extension HashtagTimelineViewModel {
class LoadLatestState: GKState {
weak var viewModel: HashtagTimelineViewModel?
init(viewModel: HashtagTimelineViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.loadLatestStateMachinePublisher.send(self)
}
}
}
extension HashtagTimelineViewModel.LoadLatestState {
class Initial: HashtagTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class Loading: HashtagTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
// sign out when loading will enter here
stateMachine.enter(Fail.self)
return
}
// TODO: only set large count when using Wi-Fi
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
hashtag: viewModel.hashTag,
authorizationBox: activeMastodonAuthenticationBox)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
// TODO: handle error
viewModel.isFetchingLatestTimeline.value = false
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
stateMachine.enter(Idle.self)
} receiveValue: { response in
let newStatusIDList = response.value.map { $0.id }
// When response data:
// 1. is not empty
// 2. last status are not recorded
// Then we may have middle data to load
if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last,
!viewModel.hashtagStatusIDList.contains(lastNewStatusID) {
viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1)
} else {
viewModel.needLoadMiddleIndex = nil
}
viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0)
viewModel.hashtagStatusIDList.removeDuplicates()
let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList)
viewModel.timelinePredicate.send(newPredicate)
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: HashtagTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: HashtagTimelineViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
}

View File

@ -0,0 +1,131 @@
//
// HashtagTimelineViewModel+LoadMiddleState.swift
// Mastodon
//
// Created by BradGao on 2021/3/31.
//
import os.log
import Foundation
import GameplayKit
import CoreData
import CoreDataStack
extension HashtagTimelineViewModel {
class LoadMiddleState: GKState {
weak var viewModel: HashtagTimelineViewModel?
let upperStatusObjectID: NSManagedObjectID
init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) {
self.viewModel = viewModel
self.upperStatusObjectID = upperStatusObjectID
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
var dict = viewModel.loadMiddleSateMachineList.value
dict[upperStatusObjectID] = stateMachine
viewModel.loadMiddleSateMachineList.value = dict // trigger value change
}
}
}
extension HashtagTimelineViewModel.LoadMiddleState {
class Initial: HashtagTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class Loading: HashtagTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// guard let viewModel = viewModel else { return false }
return stateClass == Success.self || stateClass == Fail.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else {
stateMachine.enter(Fail.self)
return
}
let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
status.id
}
// TODO: only set large count when using Wi-Fi
let maxID = upperStatusObject.id
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: maxID,
hashtag: viewModel.hashTag,
authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { completion in
// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
switch completion {
case .failure(let error):
// TODO: handle error
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
stateMachine.enter(Success.self)
let newStatusIDList = response.value.map { $0.id }
if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) {
// When response data:
// 1. is not empty
// 2. last status are not recorded
// Then we may have middle data to load
if let lastNewStatusID = newStatusIDList.last,
!viewModel.hashtagStatusIDList.contains(lastNewStatusID) {
viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count
} else {
viewModel.needLoadMiddleIndex = nil
}
viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1)
viewModel.hashtagStatusIDList.removeDuplicates()
} else {
// Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index
// Then there is no need to set a `loadMiddleState` cell
viewModel.needLoadMiddleIndex = nil
}
let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList)
viewModel.timelinePredicate.send(newPredicate)
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: HashtagTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// guard let viewModel = viewModel else { return false }
return stateClass == Loading.self
}
}
class Success: HashtagTimelineViewModel.LoadMiddleState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// guard let viewModel = viewModel else { return false }
return false
}
}
}

View File

@ -0,0 +1,121 @@
//
// HashtagTimelineViewModel+LoadOldestState.swift
// Mastodon
//
// Created by BradGao on 2021/3/31.
//
import os.log
import Foundation
import GameplayKit
import CoreDataStack
extension HashtagTimelineViewModel {
class LoadOldestState: GKState {
weak var viewModel: HashtagTimelineViewModel?
init(viewModel: HashtagTimelineViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.loadOldestStateMachinePublisher.send(self)
}
}
}
extension HashtagTimelineViewModel.LoadOldestState {
class Initial: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
return stateClass == Loading.self
}
}
class Loading: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure()
stateMachine.enter(Fail.self)
return
}
guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else {
stateMachine.enter(Idle.self)
return
}
// TODO: only set large count when using Wi-Fi
let maxID = last.id
viewModel.context.apiService.hashtagTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: maxID,
hashtag: viewModel.hashTag,
authorizationBox: activeMastodonAuthenticationBox)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { completion in
// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
} receiveValue: { response in
let toots = response.value
// enter no more state when no new toots
if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) {
stateMachine.enter(NoMore.self)
} else {
stateMachine.enter(Idle.self)
}
let newStatusIDList = toots.map { $0.id }
viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList)
let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList)
viewModel.timelinePredicate.send(newPredicate)
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class NoMore: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// reset state if needs
return stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
guard let viewModel = viewModel else { return }
guard let diffableDataSource = viewModel.diffableDataSource else {
assertionFailure()
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}

View File

@ -0,0 +1,112 @@
//
// HashtagTimelineViewModel.swift
// Mastodon
//
// Created by BradGao on 2021/3/30.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
final class HashtagTimelineViewModel: NSObject {
let hashTag: String
var disposeBag = Set<AnyCancellable>()
var hashtagStatusIDList = [Mastodon.Entity.Status.ID]()
var needLoadMiddleIndex: Int? = nil
// input
let context: AppContext
let fetchedResultsController: NSFetchedResultsController<Toot>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
weak var tableView: UITableView?
// output
// top loader
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadLatestState.Initial(viewModel: self),
LoadLatestState.Loading(viewModel: self),
LoadLatestState.Fail(viewModel: self),
LoadLatestState.Idle(viewModel: self),
])
stateMachine.enter(LoadLatestState.Initial.self)
return stateMachine
}()
lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil)
// bottom loader
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadOldestState.Initial(viewModel: self),
LoadOldestState.Loading(viewModel: self),
LoadOldestState.Fail(viewModel: self),
LoadOldestState.Idle(viewModel: self),
LoadOldestState.NoMore(viewModel: self),
])
stateMachine.enter(LoadOldestState.Initial.self)
return stateMachine
}()
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
// middle loader
let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
var cellFrameCache = NSCache<NSNumber, NSValue>()
init(context: AppContext, hashTag: String) {
self.context = context
self.hashTag = hashTag
self.fetchedResultsController = {
let fetchRequest = Toot.sortedFetchRequest
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
fetchedResultsController.delegate = self
timelinePredicate
.receive(on: DispatchQueue.main)
.compactMap { $0 }
.sink { [weak self] predicate in
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = predicate
do {
self.diffableDataSource?.defaultRowAnimation = .fade
try self.fetchedResultsController.performFetch()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.diffableDataSource?.defaultRowAnimation = .automatic
}
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
}

View File

@ -15,6 +15,7 @@ protocol StatusViewDelegate: class {
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity)
}
final class StatusView: UIView {
@ -400,6 +401,7 @@ extension StatusView {
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
playerContainerView.delegate = self
activeTextLabel.delegate = self
contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside)
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
@ -467,6 +469,13 @@ extension StatusView: AvatarConfigurableView {
var configurableVerifiedBadgeImageView: UIImageView? { nil }
}
// MARK: - ActiveLabelDelegate
extension StatusView: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
delegate?.statusView(self, didSelectActiveEntity: activeLabel, entity: entity)
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI

View File

@ -11,6 +11,7 @@ import AVKit
import Combine
import CoreData
import CoreDataStack
import ActiveLabel
protocol StatusTableViewCellDelegate: class {
var context: AppContext! { get }
@ -29,6 +30,8 @@ protocol StatusTableViewCellDelegate: class {
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity)
}
extension StatusTableViewCellDelegate {
@ -206,6 +209,10 @@ extension StatusTableViewCell: StatusViewDelegate {
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
}
func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) {
delegate?.statusTableViewCell(self, statusView: statusView, didSelectActiveEntity: entity)
}
}
// MARK: - MosaicImageViewDelegate

View File

@ -0,0 +1,70 @@
//
// APIService+HashtagTimeline.swift
// Mastodon
//
// Created by BradGao on 2021/3/30.
//
import Foundation
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import DateToolsSwift
import MastodonSDK
extension APIService {
func hashtagTimeline(
domain: String,
sinceID: Mastodon.Entity.Status.ID? = nil,
maxID: Mastodon.Entity.Status.ID? = nil,
limit: Int = onceRequestTootMaxCount,
local: Bool? = nil,
hashtag: String,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let authorization = authorizationBox.userAuthorization
let requestMastodonUserID = authorizationBox.userID
let query = Mastodon.API.Timeline.HashtagTimelineQuery(
maxID: maxID,
sinceID: sinceID,
minID: nil, // prefer sinceID
limit: limit,
local: local,
onlyMedia: false
)
return Mastodon.API.Timeline.hashtag(
session: session,
domain: domain,
query: query,
hashtag: hashtag,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
return APIService.Persist.persistStatus(
managedObjectContext: self.backgroundManagedObjectContext,
domain: domain,
query: query,
response: response,
persistType: .lookUp,
requestMastodonUserID: requestMastodonUserID,
log: OSLog.api
)
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -18,7 +18,7 @@ extension Mastodon.API.Timeline {
}
static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("tag/\(hashtag)")
.appendingPathComponent("timelines/tag/\(hashtag)")
}
/// View public timeline statuses
@ -98,17 +98,19 @@ extension Mastodon.API.Timeline {
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: `HashtagTimelineQuery` with query parameters
/// - hashtag: Content of a #hashtag, not including # symbol.
/// - authorization: User token, auth is required if public preview is disabled
/// - Returns: `AnyPublisher` contains `Token` nested in the response
public static func hashtag(
session: URLSession,
domain: String,
query: HashtagTimelineQuery,
hashtag: String
hashtag: String,
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let request = Mastodon.API.get(
url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag),
query: query,
authorization: nil
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in