HomeTimelineViewController.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. //
  2. // HomeTimelineViewController.swift
  3. // Mastodon
  4. //
  5. // Created by sxiaojian on 2021/2/5.
  6. //
  7. import os.log
  8. import UIKit
  9. import AVKit
  10. import Combine
  11. import CoreData
  12. import CoreDataStack
  13. import GameplayKit
  14. import MastodonSDK
  15. import AlamofireImage
  16. import StoreKit
  17. import MastodonAsset
  18. import MastodonCore
  19. import MastodonUI
  20. import MastodonLocalization
  21. final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
  22. let logger = Logger(subsystem: "HomeTimelineViewController", category: "UI")
  23. weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
  24. weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
  25. var disposeBag = Set<AnyCancellable>()
  26. var viewModel: HomeTimelineViewModel!
  27. let mediaPreviewTransitionController = MediaPreviewTransitionController()
  28. let friendsAssetImageView: UIImageView = {
  29. let imageView = UIImageView()
  30. imageView.image = Asset.Asset.friends.image
  31. imageView.contentMode = .scaleAspectFill
  32. return imageView
  33. }()
  34. lazy var emptyView: UIStackView = {
  35. let emptyView = UIStackView()
  36. emptyView.axis = .vertical
  37. emptyView.distribution = .fill
  38. emptyView.isLayoutMarginsRelativeArrangement = true
  39. return emptyView
  40. }()
  41. let titleView = HomeTimelineNavigationBarTitleView()
  42. let settingBarButtonItem: UIBarButtonItem = {
  43. let barButtonItem = UIBarButtonItem()
  44. barButtonItem.tintColor = ThemeService.tintColor
  45. barButtonItem.image = Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate)
  46. barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings
  47. return barButtonItem
  48. }()
  49. let tableView: UITableView = {
  50. let tableView = ControlContainableTableView()
  51. tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
  52. tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self))
  53. tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
  54. tableView.rowHeight = UITableView.automaticDimension
  55. tableView.separatorStyle = .none
  56. tableView.backgroundColor = .clear
  57. return tableView
  58. }()
  59. let publishProgressView: UIProgressView = {
  60. let progressView = UIProgressView(progressViewStyle: .bar)
  61. progressView.alpha = 0
  62. return progressView
  63. }()
  64. let refreshControl = RefreshControl()
  65. deinit {
  66. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
  67. }
  68. }
  69. extension HomeTimelineViewController {
  70. override func viewDidLoad() {
  71. super.viewDidLoad()
  72. title = L10n.Scene.HomeTimeline.title
  73. view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
  74. ThemeService.shared.currentTheme
  75. .receive(on: RunLoop.main)
  76. .sink { [weak self] theme in
  77. guard let self = self else { return }
  78. self.view.backgroundColor = theme.secondarySystemBackgroundColor
  79. }
  80. .store(in: &disposeBag)
  81. viewModel.$displaySettingBarButtonItem
  82. .receive(on: DispatchQueue.main)
  83. .sink { [weak self] displaySettingBarButtonItem in
  84. guard let self = self else { return }
  85. #if DEBUG
  86. // display debug menu
  87. self.navigationItem.rightBarButtonItem = {
  88. let barButtonItem = UIBarButtonItem()
  89. barButtonItem.image = UIImage(systemName: "ellipsis.circle")
  90. barButtonItem.menu = self.debugMenu
  91. return barButtonItem
  92. }()
  93. #else
  94. self.navigationItem.rightBarButtonItem = displaySettingBarButtonItem ? self.settingBarButtonItem : nil
  95. #endif
  96. }
  97. .store(in: &disposeBag)
  98. #if DEBUG
  99. // long press to trigger debug menu
  100. settingBarButtonItem.menu = debugMenu
  101. #else
  102. settingBarButtonItem.target = self
  103. settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:))
  104. #endif
  105. #if SNAPSHOT
  106. titleView.logoButton.menu = self.debugMenu
  107. titleView.button.menu = self.debugMenu
  108. #endif
  109. navigationItem.titleView = titleView
  110. titleView.delegate = self
  111. viewModel.homeTimelineNavigationBarTitleViewModel.state
  112. .removeDuplicates()
  113. .receive(on: DispatchQueue.main)
  114. .sink { [weak self] state in
  115. guard let self = self else { return }
  116. self.titleView.configure(state: state)
  117. }
  118. .store(in: &disposeBag)
  119. viewModel.homeTimelineNavigationBarTitleViewModel.state
  120. .removeDuplicates()
  121. .filter { $0 == .publishedButton }
  122. .receive(on: DispatchQueue.main)
  123. .sink { [weak self] _ in
  124. guard let self = self else { return }
  125. guard UserDefaults.shared.lastVersionPromptedForReview == nil else { return }
  126. guard UserDefaults.shared.processCompletedCount > 3 else { return }
  127. guard let windowScene = self.view.window?.windowScene else { return }
  128. let version = UIApplication.appVersion()
  129. UserDefaults.shared.lastVersionPromptedForReview = version
  130. SKStoreReviewController.requestReview(in: windowScene)
  131. }
  132. .store(in: &disposeBag)
  133. tableView.refreshControl = refreshControl
  134. refreshControl.addTarget(self, action: #selector(HomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
  135. tableView.translatesAutoresizingMaskIntoConstraints = false
  136. view.addSubview(tableView)
  137. NSLayoutConstraint.activate([
  138. tableView.topAnchor.constraint(equalTo: view.topAnchor),
  139. tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  140. tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  141. tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  142. ])
  143. // // layout publish progress
  144. publishProgressView.translatesAutoresizingMaskIntoConstraints = false
  145. view.addSubview(publishProgressView)
  146. NSLayoutConstraint.activate([
  147. publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
  148. publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  149. publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  150. ])
  151. viewModel.tableView = tableView
  152. tableView.delegate = self
  153. viewModel.setupDiffableDataSource(
  154. tableView: tableView,
  155. statusTableViewCellDelegate: self,
  156. timelineMiddleLoaderTableViewCellDelegate: self
  157. )
  158. // setup batch fetch
  159. viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
  160. viewModel.listBatchFetchViewModel.shouldFetch
  161. .receive(on: DispatchQueue.main)
  162. .sink { [weak self] _ in
  163. guard let self = self else { return }
  164. guard self.view.window != nil else { return }
  165. self.viewModel.loadOldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self)
  166. }
  167. .store(in: &disposeBag)
  168. // bind refresh control
  169. viewModel.didLoadLatest
  170. .receive(on: DispatchQueue.main)
  171. .sink { [weak self] _ in
  172. guard let self = self else { return }
  173. UIView.animate(withDuration: 0.5) { [weak self] in
  174. guard let self = self else { return }
  175. self.refreshControl.endRefreshing()
  176. } completion: { _ in }
  177. }
  178. .store(in: &disposeBag)
  179. context.publisherService.$currentPublishProgress
  180. .receive(on: DispatchQueue.main)
  181. .sink { [weak self] progress in
  182. guard let self = self else { return }
  183. let progress = Float(progress)
  184. guard progress > 0 else {
  185. let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
  186. dismissAnimator.addAnimations {
  187. self.publishProgressView.alpha = 0
  188. }
  189. dismissAnimator.addCompletion { _ in
  190. self.publishProgressView.setProgress(0, animated: false)
  191. }
  192. dismissAnimator.startAnimation()
  193. return
  194. }
  195. if self.publishProgressView.alpha == 0 {
  196. let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
  197. progressAnimator.addAnimations {
  198. self.publishProgressView.alpha = 1
  199. }
  200. progressAnimator.startAnimation()
  201. }
  202. self.publishProgressView.setProgress(progress, animated: true)
  203. }
  204. .store(in: &disposeBag)
  205. viewModel.timelineIsEmpty
  206. .receive(on: DispatchQueue.main)
  207. .sink { [weak self] isEmpty in
  208. if isEmpty {
  209. self?.showEmptyView()
  210. } else {
  211. self?.emptyView.removeFromSuperview()
  212. }
  213. }
  214. .store(in: &disposeBag)
  215. NotificationCenter.default
  216. .publisher(for: .statusBarTapped, object: nil)
  217. .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false)
  218. .sink { [weak self] notification in
  219. guard let self = self else { return }
  220. guard let _ = self.view.window else { return } // displaying
  221. // https://developer.limneos.net/index.php?ios=13.1.3&framework=UIKitCore.framework&header=UIStatusBarTapAction.h
  222. guard let action = notification.object as AnyObject?,
  223. let xPosition = action.value(forKey: "xPosition") as? Double
  224. else { return }
  225. let viewFrameInWindow = self.view.convert(self.view.frame, to: nil)
  226. guard xPosition >= viewFrameInWindow.minX && xPosition <= viewFrameInWindow.maxX else { return }
  227. // works on iOS 14
  228. self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): receive notification \(xPosition)")
  229. // check if scroll to top
  230. guard self.shouldRestoreScrollPosition() else { return }
  231. self.restorePositionWhenScrollToTop()
  232. }
  233. .store(in: &disposeBag)
  234. }
  235. override func viewWillAppear(_ animated: Bool) {
  236. super.viewWillAppear(animated)
  237. refreshControl.endRefreshing()
  238. tableView.deselectRow(with: transitionCoordinator, animated: animated)
  239. // needs trigger manually after onboarding dismiss
  240. setNeedsStatusBarAppearanceUpdate()
  241. }
  242. override func viewDidAppear(_ animated: Bool) {
  243. super.viewDidAppear(animated)
  244. viewModel.viewDidAppear.send()
  245. if let timestamp = viewModel.lastAutomaticFetchTimestamp {
  246. let now = Date()
  247. if now.timeIntervalSince(timestamp) > 60 {
  248. self.viewModel.lastAutomaticFetchTimestamp = now
  249. self.viewModel.homeTimelineNeedRefresh.send()
  250. } else {
  251. // do nothing
  252. }
  253. } else {
  254. self.viewModel.homeTimelineNeedRefresh.send()
  255. }
  256. }
  257. override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
  258. super.viewWillTransition(to: size, with: coordinator)
  259. coordinator.animate { _ in
  260. // do nothing
  261. } completion: { _ in
  262. // fix AutoLayout cell height not update after rotate issue
  263. self.viewModel.cellFrameCache.removeAllObjects()
  264. self.tableView.reloadData()
  265. }
  266. }
  267. }
  268. extension HomeTimelineViewController {
  269. func showEmptyView() {
  270. if emptyView.superview != nil {
  271. return
  272. }
  273. view.addSubview(emptyView)
  274. emptyView.translatesAutoresizingMaskIntoConstraints = false
  275. NSLayoutConstraint.activate([
  276. emptyView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
  277. emptyView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  278. emptyView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  279. emptyView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
  280. ])
  281. if emptyView.arrangedSubviews.count > 0 {
  282. return
  283. }
  284. let findPeopleButton: PrimaryActionButton = {
  285. let button = PrimaryActionButton()
  286. button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal)
  287. button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside)
  288. return button
  289. }()
  290. NSLayoutConstraint.activate([
  291. findPeopleButton.heightAnchor.constraint(equalToConstant: 46)
  292. ])
  293. let manuallySearchButton: HighlightDimmableButton = {
  294. let button = HighlightDimmableButton()
  295. button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
  296. button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal)
  297. button.setTitleColor(Asset.Colors.brand.color, for: .normal)
  298. button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside)
  299. return button
  300. }()
  301. let topPaddingView = UIView()
  302. let bottomPaddingView = UIView()
  303. emptyView.addArrangedSubview(topPaddingView)
  304. emptyView.addArrangedSubview(friendsAssetImageView)
  305. emptyView.addArrangedSubview(bottomPaddingView)
  306. topPaddingView.translatesAutoresizingMaskIntoConstraints = false
  307. bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
  308. NSLayoutConstraint.activate([
  309. topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 0.8),
  310. ])
  311. let buttonContainerStackView = UIStackView()
  312. emptyView.addArrangedSubview(buttonContainerStackView)
  313. buttonContainerStackView.isLayoutMarginsRelativeArrangement = true
  314. buttonContainerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 22, right: 32)
  315. buttonContainerStackView.axis = .vertical
  316. buttonContainerStackView.spacing = 17
  317. buttonContainerStackView.addArrangedSubview(findPeopleButton)
  318. buttonContainerStackView.addArrangedSubview(manuallySearchButton)
  319. }
  320. }
  321. extension HomeTimelineViewController {
  322. @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) {
  323. let suggestionAccountViewModel = SuggestionAccountViewModel(context: context, authContext: viewModel.authContext)
  324. suggestionAccountViewModel.delegate = viewModel
  325. _ = coordinator.present(
  326. scene: .suggestionAccount(viewModel: suggestionAccountViewModel),
  327. from: self,
  328. transition: .modal(animated: true, completion: nil)
  329. )
  330. }
  331. @objc private func manuallySearchButtonPressed(_ sender: UIButton) {
  332. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  333. let searchDetailViewModel = SearchDetailViewModel(authContext: viewModel.authContext)
  334. coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .modal(animated: true, completion: nil))
  335. }
  336. @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
  337. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  338. guard let setting = context.settingService.currentSetting.value else { return }
  339. let settingsViewModel = SettingsViewModel(context: context, authContext: viewModel.authContext, setting: setting)
  340. coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
  341. }
  342. @objc private func refreshControlValueChanged(_ sender: RefreshControl) {
  343. guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
  344. sender.endRefreshing()
  345. return
  346. }
  347. }
  348. @objc func signOutAction(_ sender: UIAction) {
  349. Task { @MainActor in
  350. try await context.authenticationService.signOutMastodonUser(authenticationBox: viewModel.authContext.mastodonAuthenticationBox)
  351. self.coordinator.setup()
  352. }
  353. }
  354. }
  355. // MARK: - UIScrollViewDelegate
  356. extension HomeTimelineViewController {
  357. func scrollViewDidScroll(_ scrollView: UIScrollView) {
  358. switch scrollView {
  359. case tableView:
  360. viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
  361. default:
  362. break
  363. }
  364. }
  365. func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
  366. switch scrollView {
  367. case tableView:
  368. let indexPath = IndexPath(row: 0, section: 0)
  369. guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else {
  370. return true
  371. }
  372. // save position
  373. savePositionBeforeScrollToTop()
  374. // override by custom scrollToRow
  375. tableView.scrollToRow(at: indexPath, at: .top, animated: true)
  376. return false
  377. default:
  378. assertionFailure()
  379. return true
  380. }
  381. }
  382. private func savePositionBeforeScrollToTop() {
  383. // check save action interval
  384. // should not fast than 0.5s to prevent save when scrollToTop on-flying
  385. if let record = viewModel.scrollPositionRecord {
  386. let now = Date()
  387. guard now.timeIntervalSince(record.timestamp) > 0.5 else {
  388. // skip this save action
  389. return
  390. }
  391. }
  392. guard let diffableDataSource = viewModel.diffableDataSource else { return }
  393. guard let anchorIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return }
  394. guard !anchorIndexPaths.isEmpty else { return }
  395. let anchorIndexPath = anchorIndexPaths[anchorIndexPaths.count / 2]
  396. guard let anchorItem = diffableDataSource.itemIdentifier(for: anchorIndexPath) else { return }
  397. let offset: CGFloat = {
  398. guard let anchorCell = tableView.cellForRow(at: anchorIndexPath) else { return 0 }
  399. let cellFrameInView = tableView.convert(anchorCell.frame, to: view)
  400. return cellFrameInView.origin.y
  401. }()
  402. logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): save position record for \(anchorIndexPath) with offset: \(offset)")
  403. viewModel.scrollPositionRecord = HomeTimelineViewModel.ScrollPositionRecord(
  404. item: anchorItem,
  405. offset: offset,
  406. timestamp: Date()
  407. )
  408. }
  409. private func shouldRestoreScrollPosition() -> Bool {
  410. // check if scroll to top
  411. guard self.tableView.safeAreaInsets.top > 0 else { return false }
  412. let zeroOffset = -self.tableView.safeAreaInsets.top
  413. return abs(self.tableView.contentOffset.y - zeroOffset) < 2.0
  414. }
  415. private func restorePositionWhenScrollToTop() {
  416. guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
  417. guard let record = self.viewModel.scrollPositionRecord,
  418. let indexPath = diffableDataSource.indexPath(for: record.item)
  419. else { return }
  420. tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
  421. viewModel.scrollPositionRecord = nil
  422. }
  423. }
  424. // MARK: - AuthContextProvider
  425. extension HomeTimelineViewController: AuthContextProvider {
  426. var authContext: AuthContext { viewModel.authContext }
  427. }
  428. // MARK: - UITableViewDelegate
  429. extension HomeTimelineViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
  430. // sourcery:inline:HomeTimelineViewController.AutoGenerateTableViewDelegate
  431. // Generated using Sourcery
  432. // DO NOT EDIT
  433. func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  434. aspectTableView(tableView, didSelectRowAt: indexPath)
  435. }
  436. func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
  437. return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
  438. }
  439. func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
  440. return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
  441. }
  442. func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
  443. return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
  444. }
  445. func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
  446. aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
  447. }
  448. // sourcery:end
  449. }
  450. // MARK: - TimelineMiddleLoaderTableViewCellDelegate
  451. extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
  452. func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) {
  453. guard let diffableDataSource = viewModel.diffableDataSource else { return }
  454. guard let indexPath = tableView.indexPath(for: cell) else { return }
  455. guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
  456. Task {
  457. await viewModel.loadMore(item: item)
  458. }
  459. }
  460. }
  461. // MARK: - ScrollViewContainer
  462. extension HomeTimelineViewController: ScrollViewContainer {
  463. var scrollView: UIScrollView { return tableView }
  464. func scrollToTop(animated: Bool) {
  465. if scrollView.contentOffset.y < scrollView.frame.height,
  466. viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self),
  467. (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0,
  468. !refreshControl.isRefreshing {
  469. scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated)
  470. DispatchQueue.main.async { [weak self] in
  471. guard let self = self else { return }
  472. self.refreshControl.beginRefreshing()
  473. self.refreshControl.sendActions(for: .valueChanged)
  474. }
  475. } else {
  476. let indexPath = IndexPath(row: 0, section: 0)
  477. guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return }
  478. // save position
  479. savePositionBeforeScrollToTop()
  480. tableView.scrollToRow(at: indexPath, at: .top, animated: true)
  481. }
  482. }
  483. }
  484. // MARK: - StatusTableViewCellDelegate
  485. extension HomeTimelineViewController: StatusTableViewCellDelegate { }
  486. // MARK: - HomeTimelineNavigationBarTitleViewDelegate
  487. extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
  488. func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) {
  489. if shouldRestoreScrollPosition() {
  490. restorePositionWhenScrollToTop()
  491. } else {
  492. savePositionBeforeScrollToTop()
  493. scrollToTop(animated: true)
  494. }
  495. }
  496. func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {
  497. switch titleView.state {
  498. case .newPostButton:
  499. guard let diffableDataSource = viewModel.diffableDataSource else { return }
  500. let indexPath = IndexPath(row: 0, section: 0)
  501. guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return }
  502. savePositionBeforeScrollToTop()
  503. tableView.scrollToRow(at: indexPath, at: .top, animated: true)
  504. case .offlineButton:
  505. // TODO: retry
  506. break
  507. case .publishedButton:
  508. break
  509. default:
  510. break
  511. }
  512. }
  513. }
  514. extension HomeTimelineViewController {
  515. override var keyCommands: [UIKeyCommand]? {
  516. return navigationKeyCommands + statusNavigationKeyCommands
  517. }
  518. }
  519. // MARK: - StatusTableViewControllerNavigateable
  520. extension HomeTimelineViewController: StatusTableViewControllerNavigateable {
  521. @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
  522. navigateKeyCommandHandler(sender)
  523. }
  524. @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
  525. statusKeyCommandHandler(sender)
  526. }
  527. }