// // ConversationViewController.swift // Tusker // // Created by Shadowfacts on 1/17/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import WebURL import WebURLFoundationExtras class ConversationViewController: UIViewController { weak var mastodonController: MastodonController! private(set) var mode: Mode let mainStatusState: CollapseState var statusIDToScrollToOnLoad: String? 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() case .unableToResolve(let error): showUnableToResolve(error) } updateVisibilityBarButtonItem() } } init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) { self.mode = .localID(mainStatusID) self.mainStatusState = mainStatusState self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } init(resolving url: URL, mastodonController: MastodonController) { self.mode = .resolve(url) self.mainStatusState = .unknown self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } init(preloadedTree: ConversationTree, state mainStatusState: CollapseState, mastodonController: MastodonController) { self.mode = .preloaded(preloadedTree) self.mainStatusState = mainStatusState 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 = .appSecondaryBackground 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) if case .unloaded = state { if case .preloaded(let tree) = mode { // when everything is preloaded, we're on the fast path and want to avoid any async work // just kicking off a MainActor task causes a delay before the content appears, even if the task doesn't suspend mainStatusLoaded(tree.mainStatus.status) } else { Task { @MainActor in 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], case .localID(let mainStatusID) = mode 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 @MainActor private func loadMainStatus() async { let mainStatusID: String switch mode { case .localID(let id): mainStatusID = id case .resolve(let url): if let id = await resolveStatus(url: url) { mainStatusID = id } else { return } case .preloaded(_): fatalError("unreachable") } @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() } 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() { mainStatusLoaded(status) } } } @MainActor private func resolveStatus(url: URL) async -> String? { let indicator = UIActivityIndicatorView(style: .medium) indicator.startAnimating() state = .loading(indicator) let url = WebURL(url)!.serialized(excludingFragment: true) let request = Client.search(query: url, types: [.statuses], resolve: true) do { let (results, _) = try await mastodonController.run(request) guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else { throw UnableToResolveError() } _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) mode = .localID(status.id) return status.id } catch { state = .unableToResolve(error) return nil } } private func mainStatusLoaded(_ mainStatus: StatusMO) { let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self) vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id vc.showStatusesAutomatically = showStatusesAutomatically vc.addMainStatus(mainStatus) state = .displaying(vc) if case .preloaded(let tree) = mode { vc.addTree(tree, mainStatus: mainStatus) } else { Task { @MainActor in await loadTree(for: mainStatus) } } } @MainActor private func loadTree(for mainStatus: StatusMO) async { guard case .displaying(_) = state, let context = await loadContext(for: mainStatus) else { return } await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants) let ancestorIDs = context.ancestors.map(\.id) let ancestorsReq = StatusMO.fetchRequest() ancestorsReq.predicate = NSPredicate(format: "id in %@", ancestorIDs) let ancestors = try? mastodonController.persistentContainer.viewContext.fetch(ancestorsReq) let descendantIDs = context.descendants.map(\.id) let descendantsReq = StatusMO.fetchRequest() descendantsReq.predicate = NSPredicate(format: "id IN %@", descendantIDs) let descendants = try? mastodonController.persistentContainer.viewContext.fetch(descendantsReq) let tree = ConversationTree.build(for: mainStatus, ancestors: ancestors ?? [], descendants: descendants ?? []) guard case .displaying(let vc) = state else { return } vc.addTree(tree, mainStatus: mainStatus) } private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? { let request = Status.getContext(mainStatus.id) do { let (context, _) = try await mastodonController.run(request) return context } catch { guard case .displaying(_) = state else { return nil } 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?.loadTree(for: mainStatus) } self.showToast(configuration: config, animated: true) return nil } } func refreshContext() async { guard case .localID(let id) = mode, let status = mastodonController.persistentContainer.status(for: id), case .displaying(_) = state else { return } await loadTree(for: status) } 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) } private func showUnableToResolve(_ error: Error) { let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!) image.tintColor = .secondaryLabel image.contentMode = .scaleAspectFit let title = UILabel() title.textColor = .secondaryLabel title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! title.adjustsFontForContentSizeCategory = true title.text = "Couldn't Load Post" let subtitle = UILabel() subtitle.textColor = .secondaryLabel subtitle.font = .preferredFont(forTextStyle: .body) subtitle.adjustsFontForContentSizeCategory = true subtitle.numberOfLines = 0 subtitle.textAlignment = .center if let error = error as? UnableToResolveError { subtitle.text = error.localizedDescription } else if let error = error as? Client.Error { subtitle.text = error.localizedDescription } else { subtitle.text = error.localizedDescription } var config = UIButton.Configuration.plain() config.title = "Open in Safari" config.image = UIImage(systemName: "safari") config.imagePadding = 4 let button = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in guard case .resolve(let url) = self.mode else { return } self.selected(url: url, allowResolveStatuses: false) })) let stack = UIStackView(arrangedSubviews: [ image, title, subtitle, button, ]) 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([ image.widthAnchor.constraint(equalToConstant: 64), image.heightAnchor.constraint(equalToConstant: 64), 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), ]) } // MARK: Interaction @objc func toggleCollapseButtonPressed() { guard case .displaying(let vc) = state else { return } showStatusesAutomatically = !showStatusesAutomatically vc.updateVisibleCellCollapseState() updateVisibilityBarButtonItem() } } extension ConversationViewController { enum Mode { case localID(String) case resolve(URL) case preloaded(ConversationTree) } } extension ConversationViewController { struct UnableToResolveError: Error { var localizedDescription: String { "Unable to resolve status from URL" } } } extension ConversationViewController { enum State { case unloaded case loading(UIActivityIndicatorView) case displaying(ConversationCollectionViewController) case notFound case unableToResolve(Error) } } extension ConversationViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension ConversationViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { if let accountID = mastodonController.accountInfo?.id, case .localID(let id) = mode { return UserActivityManager.showConversationActivity(mainStatusID: id, accountID: accountID) } else { return nil } } } 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 } } }