diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 80ce2d0e..428a9916 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -380,6 +380,7 @@ DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; @@ -935,6 +936,7 @@ DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; @@ -1289,6 +1291,7 @@ DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, DB6D9F6226357848008423CD /* SettingService.swift */, + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, ); path = Service; sourceTree = ""; @@ -2864,6 +2867,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 32685726..8ec70bb4 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 18 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 15 + 17 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 6feaaff4..b57be0fb 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -19,7 +19,7 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: MediaPreviewViewModel! - + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let pagingViewConttroller = MediaPreviewPagingViewController() @@ -191,11 +191,38 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) { - + // do nothing } func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { - // delegate?.mediaPreviewViewController(self, longPressGestureRecognizerTriggered: longPressGestureRecognizer) + // do nothing + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) { + switch action { + case .savePhoto: + switch viewController.viewModel.item { + case .status(let meta): + context.photoLibraryService.saveImage(url: meta.url).sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &context.disposeBag) + case .local(let meta): + context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true) + } + case .share: + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: viewController.viewModel.item.activityItems, + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView + self.present(activityViewController, animated: true, completion: nil) + } } } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index f44e1de8..ac4a4c96 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -9,9 +9,10 @@ import os.log import UIKit import Combine -protocol MediaPreviewImageViewControllerDelegate: class { +protocol MediaPreviewImageViewControllerDelegate: AnyObject { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) } final class MediaPreviewImageViewController: UIViewController { @@ -63,6 +64,9 @@ extension MediaPreviewImageViewController { previewImageView.addGestureRecognizer(tapGestureRecognizer) previewImageView.addGestureRecognizer(longPressGestureRecognizer) + let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) + previewImageView.addInteraction(previewImageViewContextMenuInteraction) + switch viewModel.item { case .status(let meta): // progressBarView.isHidden = meta.thumbnail != nil @@ -113,3 +117,50 @@ extension MediaPreviewImageViewController { } } + +// MARK: - UIContextMenuInteractionDelegate +extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let previewProvider: UIContextMenuContentPreviewProvider = { () -> UIViewController? in + return nil + } + + let saveAction = UIAction( + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto) + } + + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share) + } + + let actionProvider: UIContextMenuActionProvider = { elements -> UIMenu? in + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ + saveAction, + shareAction + ]) + } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + // set preview view + return UITargetedPreview(view: previewImageView.imageView) + } + +} + +extension MediaPreviewImageViewController { + enum ContextMenuAction { + case savePhoto + case share + } +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 0ba7d4dc..9215ef61 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -27,6 +27,19 @@ extension MediaPreviewImageViewModel { enum ImagePreviewItem { case status(RemoteImagePreviewMeta) case local(LocalImagePreviewMeta) + + var activityItems: [Any] { + var items: [Any] = [] + + switch self { + case .status(let meta): + items.append(meta.url) + case .local(let meta): + items.append(meta.image) + } + + return items + } } struct RemoteImagePreviewMeta { diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift new file mode 100644 index 00000000..44918eb5 --- /dev/null +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -0,0 +1,69 @@ +// +// PhotoLibraryService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-29. +// + +import os.log +import UIKit +import Combine +import AlamofireImage + +final class PhotoLibraryService: NSObject { + +} + +extension PhotoLibraryService { + + func saveImage(url: URL) -> AnyPublisher { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return Future { promise in + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + promise(.failure(error)) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.save(image: image) + promise(.success(image)) + } + }) + } + .handleEvents(receiveSubscription: { _ in + impactFeedbackGenerator.impactOccurred() + }, receiveCompletion: { completion in + switch completion { + case .failure: + notificationFeedbackGenerator.notificationOccurred(.error) + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + } + }) + .eraseToAnyPublisher() + } + + func save(image: UIImage, withNotificationFeedback: Bool = false) { + UIImageWriteToSavedPhotosAlbum( + image, + self, + #selector(PhotoLibraryService.image(_:didFinishSavingWithError:contextInfo:)), + nil + ) + + // assert no error + if withNotificationFeedback { + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + notificationFeedbackGenerator.notificationOccurred(.success) + } + } + + @objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + // TODO: notify banner + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 93287f6e..7a74de8b 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -30,7 +30,8 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService - + let photoLibraryService = PhotoLibraryService() + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable!