// // ConversationViewController.swift // Tusker // // Created by Shadowfacts on 1/17/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class ConversationViewController: UIViewController { weak var mastodonController: MastodonController! let mainStatusID: String let mainStatusState: CollapseState var statusIDToScrollToOnLoad: String { didSet { if case .displaying(let vc) = state { vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad } } } var showStatusesAutomatically = false { didSet { if case .displaying(let vc) = state { vc.showStatusesAutomatically = showStatusesAutomatically } } } private var collapseBarButtonItem: UIBarButtonItem! private var state: State = .unloaded { didSet { switch oldValue { case .loading(let indicator): indicator.removeFromSuperview() case .displaying(let vc): vc.removeViewAndController() default: break } switch state { case .unloaded: break case .loading(let indicator): indicator.translatesAutoresizingMaskIntoConstraints = false view.addSubview(indicator) NSLayoutConstraint.activate([ indicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), ]) case .displaying(let vc): embedChild(vc) case .notFound: showMainStatusNotFound() } updateVisibilityBarButtonItem() } } init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) { self.mainStatusID = mainStatusID self.mainStatusState = mainStatusState self.statusIDToScrollToOnLoad = mainStatusID self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() title = NSLocalizedString("Conversation", comment: "conversation screen title") view.backgroundColor = .secondarySystemBackground collapseBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "eye.fill")!, style: .plain, target: self, action: #selector(toggleCollapseButtonPressed)) updateVisibilityBarButtonItem() navigationItem.rightBarButtonItem = collapseBarButtonItem // disable transparent background when scroll to top because it looks weird when items earlier in the thread load in // (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar) let appearance = UINavigationBarAppearance() appearance.configureWithDefaultBackground() navigationItem.scrollEdgeAppearance = appearance NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) } private func updateVisibilityBarButtonItem() { switch state { case .loading(_), .displaying(_): collapseBarButtonItem.isEnabled = true default: collapseBarButtonItem.isEnabled = false } collapseBarButtonItem.isSelected = showStatusesAutomatically collapseBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task { if case .unloaded = state { await loadMainStatus() } } } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, let accountID = mastodonController.accountInfo?.id, userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } if statusIDs.contains(mainStatusID) { state = .notFound } else if case .displaying(_) = state { let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID)! Task { await loadContext(for: mainStatus) } } } // MARK: Loading private func loadMainStatus() async { @MainActor func doLoadMainStatus() async -> StatusMO? { switch await FetchStatusService(statusID: mainStatusID, mastodonController: mastodonController).run() { case .loaded(let status): return mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) case .notFound: state = .notFound return nil case .error(let error): self.showMainStatusError(error) return nil } } if let cached = mastodonController.persistentContainer.status(for: mainStatusID) { // if we have a cached copy, display it immediately but still try to refresh it Task { await doLoadMainStatus() } await mainStatusLoaded(cached) } else { // otherwise, show a loading indicator while loading the main status let indicator = UIActivityIndicatorView(style: .medium) indicator.startAnimating() state = .loading(indicator) if let status = await doLoadMainStatus() { await mainStatusLoaded(status) } } } @MainActor private func mainStatusLoaded(_ mainStatus: StatusMO) async { let vc = ConversationTableViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController) vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad vc.showStatusesAutomatically = showStatusesAutomatically vc.addMainStatus(mainStatus) state = .displaying(vc) await loadContext(for: mainStatus) } @MainActor private func loadContext(for mainStatus: StatusMO) async { guard case .displaying(_) = state else { return } let request = Status.getContext(mainStatus.id) do { let (context, _) = try await mastodonController.run(request) guard case .displaying(let vc) = state else { return } await vc.addContext(context, for: mainStatus) } catch { guard case .displaying(_) = state else { return } let error = error as! Client.Error let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadContext(for: mainStatus) } self.showToast(configuration: config, animated: true) } } private func showMainStatusNotFound() { let notFoundView = StatusNotFoundView(frame: .zero) notFoundView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(notFoundView) NSLayoutConstraint.activate([ notFoundView.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1), view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: notFoundView.trailingAnchor, multiplier: 1), notFoundView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), ]) } private func showMainStatusError(_ error: Client.Error) { let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadMainStatus() } self.showToast(configuration: config, animated: true) } // MARK: Interaction @objc func toggleCollapseButtonPressed() { guard case .displaying(let vc) = state else { return } showStatusesAutomatically = !showStatusesAutomatically vc.updateVisibleCellCollapseState() updateVisibilityBarButtonItem() } } extension ConversationViewController { enum State { case unloaded case loading(UIActivityIndicatorView) case displaying(ConversationTableViewController) case notFound } } extension ConversationViewController: ToastableViewController { var toastScrollView: UIScrollView? { if case .displaying(let vc) = state { return vc.toastScrollView } else { return nil } } } extension ConversationViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { if case .displaying(let vc) = state { return vc.handleStatusBarTapped(xPosition: xPosition) } else { return .continue } } }