Tusker/Tusker/Screens/Utilities/Previewing.swift

611 lines
32 KiB
Swift

//
// PreviewViewControllerProvider.swift
// Tusker
//
// Created by Shadowfacts on 10/10/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import SafariServices
import Pachyderm
import WebURLFoundationExtras
import SwiftUI
@MainActor
protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get }
var toastableViewController: ToastableViewController? { get }
}
protocol MenuPreviewProvider: AnyObject {
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders?
}
protocol CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController)
}
extension MenuActionProvider where Self: TuskerNavigationDelegate {
var navigationDelegate: TuskerNavigationDelegate? { self }
}
extension MenuActionProvider where Self: ToastableViewController {
var toastableViewController: ToastableViewController? { self }
}
extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, source: PopoverSource, fetchRelationship: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
var shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, source: source)
})
]
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
// user is logged out
return shareSection
}
var actionsSection: [UIMenuElement] = [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
guard let self = self else { return }
let draft = self.mastodonController!.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
})
]
var suppressSection: [UIMenuElement] = []
if let ownAccount = mastodonController.account,
accountID != ownAccount.id {
actionsSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.followAction(for: $0, mastodonController: $1) }))
actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in
var listActions = mastodonController.lists.map { list in
UIAction(title: list.title, image: UIImage(systemName: "list.bullet")) { [unowned self] _ in
let req = List.add(list.id, accounts: [accountID])
mastodonController.run(req) { response in
if case .failure(let error) = response {
self.handleError(error, title: "Error Adding to List")
}
}
}
}
listActions.append(UIAction(title: "New List…", image: UIImage(systemName: "plus"), handler: { [unowned self] _ in
Task { @MainActor in
let service = CreateListService(mastodonController: mastodonController, present: { [unowned self] in
self.navigationDelegate!.present($0, animated: true)
}) { list in
let req = List.add(list.id, accounts: [accountID])
let response = await mastodonController.runResponse(req)
if case .failure(let error) = response {
self.handleError(error, title: "Error Adding to List")
}
}
service.run()
}
}))
elementHandler([UIMenu(title: "Add to List", image: UIImage(systemName: "list.bullet"), children: listActions)])
}))
suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.hideReblogsAction(for: $0, mastodonController: $1) }))
suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.blockAction(for: $0, mastodonController: $1) }))
suppressSection.append(createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [unowned self] _ in
let view = ReportView(report: EditedReport(accountID: accountID), mastodonController: mastodonController)
let host = UIHostingController(rootView: view)
self.navigationDelegate?.present(host, animated: true)
}))
}
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showProfileActivity(id: accountID, accountID: loggedInAccountID))
return [
UIMenu(options: .displayInline, children: shareSection),
UIMenu(options: .displayInline, children: actionsSection),
UIMenu(options: .displayInline, children: suppressSection),
]
}
func actionsForURL(_ url: URL, source: PopoverSource) -> [UIAction] {
return [
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
})
]
}
func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
var actionsSection: [UIMenuElement] = []
if let mastodonController = mastodonController,
mastodonController.loggedIn {
let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
actionsSection = [
UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
if let existing = existing {
context.delete(existing)
} else {
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
}
mastodonController.persistentContainer.save(context: context)
})
]
if mastodonController.instanceFeatures.canFollowHashtags {
let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
let subtitle = "Posts tagged with followed hashtags appear in your Home timeline"
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus")
actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow()
}
})
}
}
let shareSection: [UIMenuElement]
if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, source: source)
} else {
shareSection = []
}
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
]
}
func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] }
guard let accountID = mastodonController.accountInfo?.id,
let account = mastodonController.account else {
// user is logged out
return [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
})
]
}
let bookmarked = status.bookmarked ?? false
var toggleableSection = [
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
self.mastodonController?.run(request) { (response) in
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
self.handleError(error, title: "Error \(bookmarked ? "Unb" : "B")ookmarking")
}
}
}),
]
if #available(iOS 16.0, *),
includeStatusButtonActions {
let favorited = status.favourited
// TODO: move this color into an asset catalog or something
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
if favorited {
favImage = favImage.withTintColor(UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1), renderingMode: .alwaysOriginal)
}
toggleableSection.insert(createAction(identifier: "favorite", title: favorited ? "Unfavorite" : "Favorite", image: favImage, handler: { [weak self] _ in
guard let self,
let navigationDelegate = self.navigationDelegate else { return }
Task { @MainActor in
await FavoriteService(status: status, mastodonController: mastodonController, presenter: navigationDelegate).toggleFavorite()
}
}), at: 0)
let reblogged = status.reblogged
var reblogImage = UIImage(systemName: "repeat")!
if reblogged {
reblogImage = reblogImage.withTintColor(UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1), renderingMode: .alwaysOriginal)
}
toggleableSection.insert(createAction(identifier: "reblog", title: reblogged ? "Unreblog" : "Reblog", image: reblogImage, handler: { [weak self] _ in
guard let self,
let navigationDelegate = self.navigationDelegate else { return }
Task { @MainActor in
await ReblogService(status: status, mastodonController: mastodonController, presenter: navigationDelegate).toggleReblog()
}
}), at: 1)
}
var actionsSection: [UIMenuElement] = []
if includeStatusButtonActions {
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(inReplyToID: status.id)
}), at: 0)
}
// only allow muting conversations that either current user posted or is participating in (technically, is mentioned, since that's the best we can do)
if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
let muted = status.muted
toggleableSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
self.mastodonController?.run(request) { (response) in
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
}
}
}))
}
if status.poll != nil {
actionsSection.append(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in
guard let mastodonController = self?.mastodonController else { return }
let request = Client.getStatus(id: status.id)
mastodonController.run(request, completion: { (response) in
switch response {
case .success(let status, _):
// todo: this shouldn't really use the viewContext, but for some reason saving the
// backgroundContext with the new version of the status isn't updating the viewContext
DispatchQueue.main.async {
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
}
case .failure(let error):
self?.handleError(error, title: "Error Refreshing Poll")
}
})
}))
}
if account.id == status.account.id {
if mastodonController.instanceFeatures.profilePinnedStatuses {
let pinned = status.pinned ?? false
toggleableSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (pinned ? Status.unpin : Status.pin)(status.id)
self.mastodonController?.run(request, completion: { [weak self] (response) in
guard let self = self else { return }
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
self.handleError(error, title: "Error \(pinned ? "Unp" :"P")inning")
}
})
}))
}
actionsSection.append(UIMenu(title: "Delete Post", image: UIImage(systemName: "trash"), children: [
UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
guard let self,
let navigationDelegate = self.navigationDelegate else {
return
}
Task { @MainActor in
let service = DeleteStatusService(status: status, mastodonController: mastodonController, presenter: navigationDelegate)
await service.run()
}
})
]))
} else {
actionsSection.append(createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [weak self] _ in
let report = EditedReport(accountID: status.account.id)
report.statusIDs = [status.id]
let view = ReportView(report: report, mastodonController: mastodonController)
let host = UIHostingController(rootView: view)
self?.navigationDelegate?.present(host, animated: true)
}))
}
var shareSection: [UIAction] = []
if let url = status.url {
shareSection.append(openInSafariAction(url: url))
} else {
Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)")
}
shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
}))
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
if #available(iOS 16.0, *) {
let toggleableAndActions = toggleableSection + actionsSection
return [
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
UIMenu(options: .displayInline, children: shareSection),
]
} else {
return [
UIMenu(options: .displayInline, children: shareSection),
UIMenu(options: .displayInline, children: toggleableSection),
UIMenu(options: .displayInline, children: actionsSection),
]
}
}
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {
guard let url = URL(card.url) else {
return []
}
return [
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
}),
createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
guard let self = self else { return }
let draft = self.mastodonController!.createDraft()
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
if !title.isEmpty {
draft.text += title
draft.text += ":\n"
}
draft.text += url.absoluteString
// prevents the draft from being saved automatically until the user makes a change
// also prevents it from being posted without being changed
draft.initialText = draft.text
self.navigationDelegate?.compose(editing: draft)
})
]
}
private func createAction(identifier: String, title: String, systemImageName: String?, handler: @escaping (UIAction) -> Void) -> UIAction {
let image: UIImage?
if let name = systemImageName {
image = UIImage(systemName: name)
} else {
image = nil
}
return createAction(identifier: identifier, title: title, image: image, handler: handler)
}
private func createAction(identifier: String, title: String, image: UIImage?, handler: @escaping UIActionHandler) -> UIAction {
return UIAction(title: title, image: image, identifier: UIAction.Identifier(identifier), discoverabilityTitle: nil, attributes: [], state: .off, handler: handler)
}
private func openInSafariAction(url: URL) -> UIAction {
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { [weak self] (_) in
self?.navigationDelegate?.selected(url: url, allowResolveStatuses: false, allowUniversalLinks: false)
})
}
private func addOpenInNewWindow(actions: inout [UIAction], activity: @escaping @autoclosure () -> NSUserActivity) {
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .automatic
actions.append(UIWindowScene.ActivationAction { (_) in
let activity = activity()
activity.displaysAuxiliaryScene = true
return .init(userActivity: activity, options: options, preview: nil)
})
}
private func handleError(_ error: Client.Error, title: String) {
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: title, in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
}
private func handleSuccess(title: String) {
if let toastable = self.toastableViewController {
var config = ToastConfiguration(title: title)
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
}
private func relationshipAction(_ fetch: Bool, accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
if !fetch || ProcessInfo.processInfo.isiOSAppOnMac,
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID) {
elementHandler([builder(mo, mastodonController)])
} else {
let relationship = Task {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
Task { @MainActor in
if let relationship = await relationship.value {
elementHandler([builder(relationship, mastodonController)])
} else {
elementHandler([])
}
}
}
})
}
@MainActor
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let accountID = relationship.accountID
let following = relationship.following
let requested = relationship.requested
let title = following ? "Unfollow" : requested ? "Cancel Request" : "Follow"
let imageName = following || requested ? "person.badge.minus" : "person.badge.plus"
return createAction(identifier: "follow", title: title, systemImageName: imageName) { [weak self] _ in
let request = (following || requested ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: following ? "Error Unfollowing" : requested ? "Error Cancelinng Request" : "Error Following")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
if requested { // was requested, now cancelled
self?.handleSuccess(title: "Follow Request Cancelled")
} else if following { // was following, now unfollowed
self?.handleSuccess(title: "Unfollowed")
} else if relationship.followRequested { // was not following, now requested
self?.handleSuccess(title: "Request Sent")
} else { // was not following, not now requested, assume success
self?.handleSuccess(title: "Followed")
}
}
}
}
}
@MainActor
private func blockAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let accountID = relationship.accountID
let displayName = relationship.account!.displayOrUserName
let host = relationship.account!.url.host!
let handler = { (block: Bool) in
return { [weak self] (_: UIAction) in
let req = block ? Account.block(accountID) : Account.unblock(accountID)
_ = mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: "\(block ? "B" : "Unb")locked")
}
}
}
}
let domainHandler = { (block: Bool) in
return { [weak self] (_: UIAction) in
let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
case .success(_, _):
self?.handleSuccess(title: "Domain \(block ? "B" : "Unb")locked")
}
}
}
}
if relationship.domainBlocking {
return createAction(identifier: "block", title: "Unblock \(host)", systemImageName: "circle.slash", handler: domainHandler(false))
} else if relationship.blocking {
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
} else {
let image = UIImage(systemName: "circle.slash")
return UIMenu(title: "Block", image: image, children: [
UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
])
}
}
@MainActor
private func muteAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
if relationship.muting || relationship.mutingNotifications {
return UIAction(title: "Unmute", image: UIImage(systemName: "speaker")) { [weak self] _ in
let req = Account.unmute(relationship.accountID)
mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error Unmuting")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: "Unmuted")
}
}
}
} else {
return UIAction(title: "Mute", image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
let view = MuteAccountView(account: relationship.account!, mastodonController: mastodonController)
let host = UIHostingController(rootView: view)
self?.navigationDelegate?.present(host, animated: true)
}
}
}
@MainActor
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
// todo: need alternate repeat icon to use here
return UIAction(title: title, image: nil) { [weak self] _ in
let req = Account.setShowReblogs(relationship.accountID, showReblogs: !relationship.showingReblogs)
mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(relationship.showingReblogs ? "Hiding" : "Showing") Reblogs")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: relationship.showingReblogs ? "Reblogs Shown" : "Reblogs Hidden")
}
}
}
}
}
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
let req = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(req),
let r = relationships.first else {
return nil
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
continuation.resume(returning: mo)
}
}
}
struct MenuPreviewHelper {
static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: presenter)
} else {
presenter.show(viewController, sender: nil)
}
}
}
}
}
extension LargeImageViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)
}
}
extension GalleryViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)
}
}
extension SFSafariViewController: CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) {
presenter.present(self, animated: true)
}
}