// // 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) } }