476 lines
18 KiB
Swift
476 lines
18 KiB
Swift
//
|
|
// ConversationViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 1/17/23.
|
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Pachyderm
|
|
import WebURL
|
|
import WebURLFoundationExtras
|
|
|
|
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
|
|
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
|
|
let path = url.path
|
|
let range = NSRange(location: 0, length: path.utf16.count)
|
|
return mastodonRemoteStatusRegex.numberOfMatches(in: path, range: range) == 1
|
|
}
|
|
|
|
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: mastodonController)
|
|
}
|
|
|
|
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 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 effectiveURL: String
|
|
class RedirectBlocker: NSObject, URLSessionTaskDelegate {
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
if isLikelyMastodonRemoteStatus(url: url),
|
|
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
|
|
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
|
effectiveURL = location
|
|
} else {
|
|
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
|
}
|
|
|
|
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
|
do {
|
|
let (results, _) = try await mastodonController.run(request)
|
|
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) 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) {
|
|
if let accountID = mastodonController.accountInfo?.id {
|
|
userActivity = UserActivityManager.showConversationActivity(mainStatusID: mainStatus.id, accountID: accountID)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|