diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 405a5113..ee6135fb 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -8,20 +8,16 @@ import UIKit import Pachyderm +import WebURL +import WebURLFoundationExtras class ConversationViewController: UIViewController { weak var mastodonController: MastodonController! - let mainStatusID: String + private(set) var mode: Mode let mainStatusState: CollapseState - var statusIDToScrollToOnLoad: String { - didSet { - if case .displaying(let vc) = state { - vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad - } - } - } + var statusIDToScrollToOnLoad: String? var showStatusesAutomatically = false { didSet { if case .displaying(let vc) = state { @@ -57,6 +53,8 @@ class ConversationViewController: UIViewController { embedChild(vc) case .notFound: showMainStatusNotFound() + case .unableToResolve(let error): + showUnableToResolve(error) } updateVisibilityBarButtonItem() @@ -64,9 +62,16 @@ class ConversationViewController: UIViewController { } init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) { - self.mainStatusID = mainStatusID + self.mode = .localID(mainStatusID) self.mainStatusState = mainStatusState - self.statusIDToScrollToOnLoad = mainStatusID + 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) @@ -121,7 +126,8 @@ class ConversationViewController: UIViewController { guard let userInfo = notification.userInfo, let accountID = mastodonController.accountInfo?.id, userInfo["accountID"] as? String == accountID, - let statusIDs = userInfo["statusIDs"] as? [String] else { + let statusIDs = userInfo["statusIDs"] as? [String], + case .localID(let mainStatusID) = mode else { return } if statusIDs.contains(mainStatusID) { @@ -137,6 +143,10 @@ class ConversationViewController: UIViewController { // MARK: Loading private func loadMainStatus() async { + guard let mainStatusID = await resolveStatusIfNecessary() else { + return + } + @MainActor func doLoadMainStatus() async -> StatusMO? { switch await FetchStatusService(statusID: mainStatusID, mastodonController: mastodonController).run() { @@ -169,10 +179,37 @@ class ConversationViewController: UIViewController { } } + @MainActor + private func resolveStatusIfNecessary() async -> String? { + switch mode { + case .localID(let id): + return id + case .resolve(let url): + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + state = .loading(indicator) + + let url = WebURL(url)! + let request = Client.search(query: url.serialized(), types: [.statuses], resolve: true) + do { + let (results, _) = try await mastodonController.run(request) + guard let status = results.statuses.first(where: { $0.url == url }) else { + throw UnableToResolveError() + } + _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) + mode = .localID(status.id) + return status.id + } catch { + state = .unableToResolve(error) + return nil + } + } + } + @MainActor private func mainStatusLoaded(_ mainStatus: StatusMO) async { - let vc = ConversationCollectionViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController) - vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad + let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController) + vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id vc.showStatusesAutomatically = showStatusesAutomatically vc.addMainStatus(mainStatus) state = .displaying(vc) @@ -227,6 +264,66 @@ class ConversationViewController: UIViewController { 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() { @@ -240,15 +337,35 @@ class ConversationViewController: UIViewController { } +extension ConversationViewController { + enum Mode { + case localID(String) + case resolve(URL) + } +} + +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: ToastableViewController { var toastScrollView: UIScrollView? { if case .displaying(let vc) = state { diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index b4d4d62c..acc5b4a3 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -44,7 +44,7 @@ extension TuskerNavigationDelegate { show(HashtagTimelineViewController(for: tag, mastodonController: apiController), sender: self) } - func selected(url: URL, allowUniversalLinks: Bool = true) { + func selected(url: URL, allowResolveStatuses: Bool = true, allowUniversalLinks: Bool = true) { func openSafari() { if Preferences.shared.useInAppSafari, url.scheme == "https" || url.scheme == "http" { @@ -66,8 +66,12 @@ extension TuskerNavigationDelegate { } } - if allowUniversalLinks && Preferences.shared.openLinksInApps, - url.scheme == "https" || url.scheme == "http" { + if allowResolveStatuses, + isLikelyResolvableAsStatus(url) { + show(ConversationViewController(resolving: url, mastodonController: apiController)) + } else if allowUniversalLinks, + Preferences.shared.openLinksInApps, + url.scheme == "https" || url.scheme == "http" { UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in if (!success) { openSafari() @@ -217,3 +221,21 @@ enum PopoverSource { .barButtonItem(WeakHolder(item)) } } + +private let statusPathRegex = try! NSRegularExpression( + pattern: + "(^/@[a-z0-9_]+/\\d{18})" // mastodon + + "|(^/notice/[a-z0-9]{18})" // pleroma + + "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed + + "|(^/i/web/post/\\d{18})" // pixelfed web frontend + + "|(^/u/.+/h/[a-z0-9]{18})" // honk + + "|(^/@.+/statuses/[a-z0-9]{26})" // gotosocial + , + options: .caseInsensitive +) + +private func isLikelyResolvableAsStatus(_ url: URL) -> Bool { + let path = url.path + let range = NSRange(location: 0, length: path.utf16.count) + return statusPathRegex.numberOfMatches(in: path, range: range) == 1 +}