// // TuskerNavigationDelegate.swift // Tusker // // Created by Shadowfacts on 9/30/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import SafariServices import Pachyderm import ComposeUI import GalleryVC @MainActor protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { nonisolated var apiController: MastodonController! { get } } extension TuskerNavigationDelegate { func show(_ vc: UIViewController) { if vc is SFSafariViewController { present(vc, animated: true) } else { show(vc, sender: self) } } func selected(account accountID: String) { // don't open if the account is the same as the current one if let profileController = self as? ProfileStatusesViewController, profileController.accountID == accountID { return } show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) } func selected(mention: Mention) { show(ProfileViewController(accountID: mention.id, mastodonController: apiController), sender: self) } func selected(tag: Hashtag) { show(HashtagTimelineViewController(for: tag, mastodonController: apiController), sender: self) } func selected(url: URL, allowResolveStatuses: Bool = true, allowUniversalLinks: Bool = true) { func openSafari() { #if targetEnvironment(macCatalyst) || os(visionOS) UIApplication.shared.open(url) #else if !ProcessInfo.processInfo.isiOSAppOnMac, Preferences.shared.useInAppSafari, url.scheme == "https" || url.scheme == "http" { let config = SFSafariViewController.Configuration() config.entersReaderIfAvailable = Preferences.shared.inAppSafariAutomaticReaderMode let vc = SFSafariViewController(url: url, configuration: config) vc.preferredControlTintColor = Preferences.shared.accentColor.color present(vc, animated: true) } else if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:]) } else { var message = "The URL could not be opened." if let scheme = url.scheme { message += " This can happen if you do not have an app installed for '\(scheme)://' URLs." } let alert = UIAlertController(title: "Invalid URL", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) present(alert, animated: true) } #endif } 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() } } } else { openSafari() } } func selected(status statusID: String) { self.selected(status: statusID, state: .unknown) } func selected(status statusID: String, state: CollapseState) { show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self) } func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false) { let draft = draft ?? apiController.createDraft() let visionIdiom = UIUserInterfaceIdiom(rawValue: 6) // .vision is not available pre-iOS 17 :S if [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom) { let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id) let options = UIWindowScene.ActivationRequestOptions() #if os(visionOS) options.placement = .prominent() #else options.preferredPresentationStyle = .prominent #endif UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController) #if os(visionOS) fatalError("unreachable") #else if #available(iOS 16.0, *), presentDuckable(compose, animated: animated, isDucked: isDucked) { return } else { present(compose, animated: animated) } #endif } } func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil, animated: Bool = true) { let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct) compose(editing: draft, animated: animated) } private func moreOptions(forURL url: URL) -> UIActivityViewController { let customActivites: [UIActivity] = [ OpenInSafariActivity() ] let activityController = UIActivityViewController(activityItems: [url], applicationActivities: customActivites) activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: url) return activityController } private func moreOptions(forStatus statusID: String) -> UIActivityViewController { guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") } guard let url = status.url else { Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)") fatalError("Cannot create UIActivityViewController for status without URL") } return UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: nil) } private func moreOptions(forAccount accountID: String) -> UIActivityViewController { guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil) } func showMoreOptions(forStatus statusID: String, source: PopoverSource) { let vc = moreOptions(forStatus: statusID) source.apply(to: vc) present(vc, animated: true) } func showMoreOptions(forURL url: URL, source: PopoverSource) { let vc = moreOptions(forURL: url) source.apply(to: vc) present(vc, animated: true) } func showMoreOptions(forAccount accountID: String, source: PopoverSource) { let vc = moreOptions(forAccount: accountID) source.apply(to: vc) present(vc, animated: true) } } enum PopoverSource { case none case view(WeakHolder) case barButtonItem(WeakHolder) @MainActor func apply(to viewController: UIViewController) { if let popoverPresentationController = viewController.popoverPresentationController { switch self { case .none: break case .view(let view): popoverPresentationController.sourceView = view.object case .barButtonItem(let item): popoverPresentationController.barButtonItem = item.object } } } static func view(_ view: UIView?) -> Self { .view(WeakHolder(view)) } static func barButtonItem(_ item: UIBarButtonItem?) -> Self { .barButtonItem(WeakHolder(item)) } } private let statusPathRegex = try! NSRegularExpression( pattern: "(^/@[a-z0-9_]+/\\d{18})" // mastodon + "|(^/@.+@.+/\\d{18})" // mastodon remote + "|(^/notice/[a-z0-9]{18})" // pleroma + "|(^/notes/[a-z0-9]{10})" // misskey + "|(^/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 }