// // TimelineTableViewController.swift // Tusker // // Created by Shadowfacts on 8/15/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm typealias TimelineEntry = (id: String, state: StatusState) class TimelineTableViewController: DiffableTimelineLikeTableViewController { let timeline: Timeline weak var mastodonController: MastodonController! private var newer: RequestRange? private var older: RequestRange? private var didConfirmLoadMore = false private var isShowingTimelineDescription = false init(for timeline: Timeline, mastodonController: MastodonController) { self.timeline = timeline self.mastodonController = mastodonController super.init() dragEnabled = true title = timeline.title tabBarItem.image = timeline.tabBarImage if let id = mastodonController.accountInfo?.id { userActivity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: id) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { guard let persistentContainer = mastodonController?.persistentContainer, let dataSource = dataSource else { return } // decrement reference counts of any statuses we still have // if the app is currently being quit, this will not affect the persisted data because // the persistent container would already have been saved in SceneDelegate.sceneDidEnterBackground(_:) // todo: remove the whole reference count system for case let .status(id: id, state: _) in dataSource.snapshot().itemIdentifiers { persistentContainer.status(for: id)?.decrementReferenceCount() } } override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell") tableView.register(UINib(nibName: "ConfirmLoadMoreTableViewCell", bundle: .main), forCellReuseIdentifier: "confirmLoadMoreCell") tableView.register(UINib(nibName: "PublicTimelineDescriptionTableViewCell", bundle: .main), forCellReuseIdentifier: "publicTimelineDescriptionCell") if case let .public(local: local) = timeline, (local && !Preferences.shared.hasShownLocalTimelineDescription) || (!local && !Preferences.shared.hasShownFederatedTimelineDescription) { isShowingTimelineDescription = true var snapshot = self.dataSource.snapshot() snapshot.appendSections([.header]) snapshot.appendItems([.publicTimelineDescription(local: local)], toSection: .header) self.dataSource.apply(snapshot, animatingDifferences: false) } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if case let .public(local: local) = timeline { if local { Preferences.shared.hasShownLocalTimelineDescription = true } else { Preferences.shared.hasShownFederatedTimelineDescription = true } } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if isShowingTimelineDescription { isShowingTimelineDescription = false var snapshot = self.dataSource.snapshot() snapshot.deleteSections([.header]) self.dataSource.apply(snapshot, animatingDifferences: false) } } // MARK: - DiffableTimelineLikeTableViewController override class func refreshCommandTitle() -> String { return NSLocalizedString("Refresh Statuses", comment: "refresh status command discoverability title") } override func timelineContentSections() -> [Section] { return [.statuses] } override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { switch item { case let .status(id: id, state: state): let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell cell.delegate = self cell.updateUI(statusID: id, state: state) return cell case .confirmLoadMore: let cell = tableView.dequeueReusableCell(withIdentifier: "confirmLoadMoreCell", for: indexPath) as! ConfirmLoadMoreTableViewCell cell.confirmLoadMore = { self.didConfirmLoadMore = true self.loadOlder() self.didConfirmLoadMore = false } return cell case .publicTimelineDescription(local: let local): let cell = tableView.dequeueReusableCell(withIdentifier: "publicTimelineDescriptionCell", for: indexPath) as! PublicTimelineDescriptionTableViewCell cell.mastodonController = mastodonController cell.local = local cell.didDismiss = { [unowned self] in var snapshot = self.dataSource.snapshot() snapshot.deleteSections([.header]) self.dataSource.apply(snapshot) } return cell } } override func loadInitialItems(completion: @escaping (LoadResult) -> Void) { guard let mastodonController = mastodonController else { completion(.failure(.noClient)) return } let request = Client.getStatuses(timeline: timeline) mastodonController.run(request) { response in switch response { case let .failure(error): completion(.failure(.client(error))) case let .success(statuses, pagination): self.newer = pagination?.newer self.older = pagination?.older self.mastodonController.persistentContainer.addAll(statuses: statuses) { DispatchQueue.main.async { var snapshot = self.dataSource.snapshot() snapshot.deleteSections([.statuses, .footer]) snapshot.appendSections([.statuses, .footer]) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses) completion(.success(snapshot)) } } } } } override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { guard let older = older else { completion(.failure(.noOlder)) return } if #available(iOS 15.0, *), Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore { var snapshot = currentSnapshot() guard !snapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else { // todo: need something more accurate than "success"/"failure" completion(.success(snapshot)) return } snapshot.appendItems([.confirmLoadMore], toSection: .footer) self.dataSource.apply(snapshot) completion(.success(snapshot)) return } let request = Client.getStatuses(timeline: timeline, range: older) mastodonController.run(request) { response in switch response { case let .failure(error): completion(.failure(.client(error))) case let .success(statuses, pagination): self.older = pagination?.older self.mastodonController.persistentContainer.addAll(statuses: statuses) { var snapshot = currentSnapshot() snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses) snapshot.deleteItems([.confirmLoadMore]) completion(.success(snapshot)) } } } } override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { guard let newer = newer else { completion(.failure(.noNewer)) return } let request = Client.getStatuses(timeline: timeline, range: newer) mastodonController.run(request) { response in switch response { case let .failure(error): completion(.failure(.client(error))) case let .success(statuses, pagination): guard !statuses.isEmpty else { completion(.failure(.allCaughtUp)) return } // if there are no new statuses, pagination is nil // if we were to then overwrite self.newer, future refresh would fail if let newer = pagination?.newer { self.newer = newer } self.mastodonController.persistentContainer.addAll(statuses: statuses) { var snapshot = currentSnapshot() let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) } if let first = snapshot.itemIdentifiers(inSection: .statuses).first { snapshot.insertItems(newIdentifiers, beforeItem: first) } else { snapshot.appendItems(newIdentifiers, toSection: .statuses) } completion(.success(snapshot)) } } } } override func willRemoveItems(_ items: [Item]) { for case let .status(id: id, state: _) in items { mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount() } } override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { super.scrollViewWillBeginDragging(scrollView) if isShowingTimelineDescription { var snapshot = self.dataSource.snapshot() snapshot.deleteSections([.header]) self.dataSource.apply(snapshot, animatingDifferences: true) } } } extension TimelineTableViewController { enum Section: Hashable, CaseIterable { case header case statuses case footer } enum Item: Hashable { case status(id: String, state: StatusState) case confirmLoadMore case publicTimelineDescription(local: Bool) static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.status(id: a, state: _), .status(id: b, state: _)): return a == b case (.confirmLoadMore, .confirmLoadMore): return true case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)): return a == b default: return false } } func hash(into hasher: inout Hasher) { switch self { case let .status(id: id, state: _): hasher.combine(0) hasher.combine(id) case .confirmLoadMore: hasher.combine(1) case let .publicTimelineDescription(local: local): hasher.combine(2) hasher.combine(local) } } } } extension TimelineTableViewController: TuskerNavigationDelegate { var apiController: MastodonController { mastodonController } } extension TimelineTableViewController: StatusTableViewCellDelegate { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { cellHeightChanged() } } extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { let ids: [String] = indexPaths.compactMap { if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) { return id } else { return nil } } prefetchStatuses(with: ids) } func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { let ids: [String] = indexPaths.compactMap { if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) { return id } else { return nil } } cancelPrefetchingStatuses(with: ids) } }