// // ProfileStatusesViewController.swift // Tusker // // Created by Shadowfacts on 7/3/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class ProfileStatusesViewController: EnhancedTableViewController { weak var mastodonController: MastodonController! private(set) var headerView: ProfileHeaderView! var accountID: String! let kind: Kind private var pinnedStatuses: [(id: String, state: StatusState)] = [] private var timelineSegments: [[(id: String, state: StatusState)]] = [] private var older: RequestRange? private var newer: RequestRange? var loaded = false init(accountID: String?, kind: Kind, mastodonController: MastodonController) { self.accountID = accountID self.kind = kind self.mastodonController = mastodonController super.init(style: .plain) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground #if !targetEnvironment(macCatalyst) refreshControl = UIRefreshControl() refreshControl!.addTarget(self, action: #selector(refreshStatuses), for: .valueChanged) #endif addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title"))) tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 140 tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell") tableView.prefetchDataSource = self } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if !loaded, let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) { updateUI(account: account) } } func updateUI(account: AccountMO) { guard !loaded else { return } loaded = true if kind == .statuses { getPinnedStatuses { (response) in guard case let .success(statuses, _) = response else { // todo: error message return } if statuses.isEmpty { return } self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.pinnedStatuses = statuses.map { ($0.id, .unknown) } let indexPaths = (0..) { let request: Request<[Status]> switch kind { case .statuses: request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) case .withReplies: request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) case .onlyMedia: request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) } mastodonController.run(request, completion: completion) } private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) { let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) mastodonController.run(request, completion: completion) } // MARK: Interaction @objc func refreshStatuses() { guard let newer = newer else { return } getStatuses(for: newer) { (response) in guard case let .success(newStatuses, pagination) = response else { // todo: error message return } self.mastodonController.persistentContainer.addAll(statuses: newStatuses) { // if there's no newer request range (because no statuses were returned), // we don't want to change the current newer pagination, so that we can // continue to load statuses newer than whatever was last loaded if let newer = pagination?.newer { self.newer = newer } let indexPaths = (0.. (id: String, state: StatusState) in let state: StatusState if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) { state = oldState } else { state = .unknown } return (status.id, state) } DispatchQueue.main.async { self.pinnedStatuses = pinnedStatuses UIView.performWithoutAnimation { self.tableView.reloadSections(IndexSet(integer: 0), with: .none) } } } } } } // MARK: Table view data source override func numberOfSections(in tableView: UITableView) -> Int { // 1 for pinned, rest for timeline return 1 + timelineSegments.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { return pinnedStatuses.count } else { return timelineSegments[section - 1].count } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell cell.delegate = self if indexPath.section == 0 { cell.showPinned = true let (id, state) = pinnedStatuses[indexPath.row] cell.updateUI(statusID: id, state: state) } else { cell.showPinned = false let (id, state) = timelineSegments[indexPath.section - 1][indexPath.row] cell.updateUI(statusID: id, state: state) } return cell } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { // load older statuses if at bottom if timelineSegments.count > 0, indexPath.section == timelineSegments.count, indexPath.row == timelineSegments[indexPath.section - 1].count - 1 { guard let older = older else { return } getStatuses(for: older) { (response) in guard case let .success(newStatuses, pagination) = response else { // todo: error message return } self.mastodonController.persistentContainer.addAll(statuses: newStatuses) { // if there is no older request range, we want to set ours to nil // otherwise we would end up loading the same statuses again self.older = pagination?.older DispatchQueue.main.async { let start = self.timelineSegments[indexPath.section - 1].count let indexPaths = (0.. Bool { return true } override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration() } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration() } } extension ProfileStatusesViewController { enum Kind { case statuses, withReplies, onlyMedia } } extension ProfileStatusesViewController: StatusTableViewCellDelegate { var apiController: MastodonController { mastodonController } func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { // causes the table view to recalculate the cell heights tableView.beginUpdates() tableView.endUpdates() } } extension ProfileStatusesViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { let statusID: String if indexPath.section == 0 { statusID = pinnedStatuses[indexPath.row].id } else { statusID = timelineSegments[indexPath.section - 1][indexPath.row].id } guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue } _ = ImageCache.avatars.get(status.account.avatar, completion: nil) for attachment in status.attachments where attachment.kind == .image { _ = ImageCache.attachments.get(attachment.url, completion: nil) } } } func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { let statusID: String if indexPath.section == 0 { statusID = pinnedStatuses[indexPath.row].id } else { statusID = timelineSegments[indexPath.section - 1][indexPath.row].id } guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue } ImageCache.avatars.cancelWithoutCallback(status.account.avatar) for attachment in status.attachments where attachment.kind == .image { ImageCache.avatars.cancelWithoutCallback(attachment.url) } } } } extension ProfileStatusesViewController: RefreshableViewController { func refresh() { refreshStatuses() } }