Try to resolve statuses from links that match known patterns

This commit is contained in:
Shadowfacts 2023-01-21 13:56:39 -05:00
parent 63ed3b6e10
commit 2229b332e0
2 changed files with 155 additions and 16 deletions

View File

@ -8,20 +8,16 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
import WebURLFoundationExtras
class ConversationViewController: UIViewController { class ConversationViewController: UIViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let mainStatusID: String private(set) var mode: Mode
let mainStatusState: CollapseState let mainStatusState: CollapseState
var statusIDToScrollToOnLoad: String { var statusIDToScrollToOnLoad: String?
didSet {
if case .displaying(let vc) = state {
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad
}
}
}
var showStatusesAutomatically = false { var showStatusesAutomatically = false {
didSet { didSet {
if case .displaying(let vc) = state { if case .displaying(let vc) = state {
@ -57,6 +53,8 @@ class ConversationViewController: UIViewController {
embedChild(vc) embedChild(vc)
case .notFound: case .notFound:
showMainStatusNotFound() showMainStatusNotFound()
case .unableToResolve(let error):
showUnableToResolve(error)
} }
updateVisibilityBarButtonItem() updateVisibilityBarButtonItem()
@ -64,9 +62,16 @@ class ConversationViewController: UIViewController {
} }
init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) { init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID self.mode = .localID(mainStatusID)
self.mainStatusState = mainStatusState 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 self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -121,7 +126,8 @@ class ConversationViewController: UIViewController {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id, let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID, 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 return
} }
if statusIDs.contains(mainStatusID) { if statusIDs.contains(mainStatusID) {
@ -137,6 +143,10 @@ class ConversationViewController: UIViewController {
// MARK: Loading // MARK: Loading
private func loadMainStatus() async { private func loadMainStatus() async {
guard let mainStatusID = await resolveStatusIfNecessary() else {
return
}
@MainActor @MainActor
func doLoadMainStatus() async -> StatusMO? { func doLoadMainStatus() async -> StatusMO? {
switch await FetchStatusService(statusID: mainStatusID, mastodonController: mastodonController).run() { 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 @MainActor
private func mainStatusLoaded(_ mainStatus: StatusMO) async { private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationCollectionViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController) let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus) vc.addMainStatus(mainStatus)
state = .displaying(vc) state = .displaying(vc)
@ -227,6 +264,66 @@ class ConversationViewController: UIViewController {
self.showToast(configuration: config, animated: true) 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 // MARK: Interaction
@objc func toggleCollapseButtonPressed() { @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 { extension ConversationViewController {
enum State { enum State {
case unloaded case unloaded
case loading(UIActivityIndicatorView) case loading(UIActivityIndicatorView)
case displaying(ConversationCollectionViewController) case displaying(ConversationCollectionViewController)
case notFound case notFound
case unableToResolve(Error)
} }
} }
extension ConversationViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension ConversationViewController: ToastableViewController { extension ConversationViewController: ToastableViewController {
var toastScrollView: UIScrollView? { var toastScrollView: UIScrollView? {
if case .displaying(let vc) = state { if case .displaying(let vc) = state {

View File

@ -44,7 +44,7 @@ extension TuskerNavigationDelegate {
show(HashtagTimelineViewController(for: tag, mastodonController: apiController), sender: self) 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() { func openSafari() {
if Preferences.shared.useInAppSafari, if Preferences.shared.useInAppSafari,
url.scheme == "https" || url.scheme == "http" { url.scheme == "https" || url.scheme == "http" {
@ -66,8 +66,12 @@ extension TuskerNavigationDelegate {
} }
} }
if allowUniversalLinks && Preferences.shared.openLinksInApps, if allowResolveStatuses,
url.scheme == "https" || url.scheme == "http" { 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 UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
if (!success) { if (!success) {
openSafari() openSafari()
@ -217,3 +221,21 @@ enum PopoverSource {
.barButtonItem(WeakHolder(item)) .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
}