// // 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 } 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 { await loadMainStatus() } } // 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 showMainStatusNotFound() 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 emoji = UILabel() emoji.font = .systemFont(ofSize: 64) emoji.text = "🤷" let title = UILabel() title.textColor = .secondaryLabel title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! title.adjustsFontForContentSizeCategory = true title.text = "Not Found" let subtitle = UILabel() subtitle.textColor = .secondaryLabel subtitle.font = .preferredFont(forTextStyle: .body) subtitle.adjustsFontForContentSizeCategory = true subtitle.text = "The post you are looking for may have been deleted, or may not be visible to you." subtitle.numberOfLines = 0 subtitle.textAlignment = .center let stack = UIStackView(arrangedSubviews: [ emoji, title, subtitle, ]) stack.axis = .vertical stack.alignment = .center stack.spacing = 8 stack.isAccessibilityElement = true stack.accessibilityLabel = "\(title.text!). \(subtitle.text!)" stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1), view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1), stack.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 { }