diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 428a99162..02ff93ccc 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -315,6 +315,7 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; @@ -381,6 +382,9 @@ 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 */; }; + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; }; + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; }; + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.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 */; }; @@ -869,6 +873,7 @@ DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; @@ -937,6 +942,9 @@ 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 = ""; }; + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = ""; }; + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = ""; }; + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.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 = ""; }; @@ -1272,6 +1280,7 @@ DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, ); path = Vender; sourceTree = ""; @@ -1372,6 +1381,7 @@ DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, + DBA5E7A6263BD298004598BB /* ContextMenu */, ); path = Share; sourceTree = ""; @@ -2201,6 +2211,24 @@ path = Helper; sourceTree = ""; }; + DBA5E7A6263BD298004598BB /* ContextMenu */ = { + isa = PBXGroup; + children = ( + DBA5E7A7263BD29F004598BB /* ImagePreview */, + ); + path = ContextMenu; + sourceTree = ""; + }; + DBA5E7A7263BD29F004598BB /* ImagePreview */ = { + isa = PBXGroup; + children = ( + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */, + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */, + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */, + ); + path = ImagePreview; + sourceTree = ""; + }; DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( @@ -2883,6 +2911,7 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -2943,6 +2972,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, @@ -2957,6 +2987,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, @@ -3036,6 +3067,7 @@ DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 8ec70bb4e..326857269 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 - 18 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b897de47f..519637b88 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -12,7 +12,7 @@ import os.log import UIKit import AVKit -protocol StatusCell : DisposeBagCollectable { +protocol StatusCell: DisposeBagCollectable { var statusView: StatusView { get } var pollCountdownSubscription: AnyCancellable? { get set } } @@ -142,7 +142,7 @@ extension StatusSection { status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute - ) { + ) { // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index cd6cbf589..ef19abab9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -106,4 +106,252 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } -extension StatusTableViewCellDelegate where Self: StatusProvider {} +extension StatusTableViewCellDelegate where Self: StatusProvider { + + private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable + + func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil } + guard imagePreviewPresentableCell.isRevealing else { return nil } + + let status = status(for: nil, indexPath: indexPath) + + return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + private func contextMenuConfiguration( + _ tableView: UITableView, + status: Future, + imagePreviewPresentableCell presentable: ImagePreviewPresentableCell, + contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + let imageViews = presentable.mosaicImageViewContainer.imageViews + guard !imageViews.isEmpty else { return nil } + + for (i, imageView) in imageViews.enumerated() { + let pointInImageView = imageView.convert(point, from: tableView) + guard imageView.point(inside: pointInImageView, with: nil) else { + continue + } + guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else { + // not provide preview until image ready + return nil + + } + // setup preview + let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image) + status + .sink { status in + guard let status = (status?.reblog ?? status), + let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }), + i < media.count, let url = URL(string: media[i].url) else { + return + } + + contextMenuImagePreviewViewModel.url.value = url + } + .store(in: &contextMenuImagePreviewViewModel.disposeBag) + + // setup context menu + let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in + // know issue: preview size looks not as large as system default preview + let previewProvider = ContextMenuImagePreviewViewController() + previewProvider.viewModel = contextMenuImagePreviewViewModel + return previewProvider + } actionProvider: { _ -> UIMenu? in + let savePhotoAction = 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.attachment(of: status, index: i) + .setFailureType(to: Error.self) + .compactMap { attachment -> AnyPublisher? in + guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } + return self.context.photoLibraryService.saveImage(url: url) + } + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { _ in + // do nothing + }) + .store(in: &self.context.disposeBag) + } + 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.attachment(of: status, index: i) + .sink(receiveValue: { [weak self] attachment in + guard let self = self else { return } + guard let attachment = attachment, let url = URL(string: attachment.url) else { return } + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: [url], + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = imageView + self.present(activityViewController, animated: true, completion: nil) + }) + .store(in: &self.context.disposeBag) + } + let children = [savePhotoAction, shareAction] + return UIMenu(title: "", image: nil, children: children) + } + contextMenuConfiguration.indexPath = indexPath + contextMenuConfiguration.index = i + return contextMenuConfiguration + } + + return nil + } + + private func attachment(of status: Future, index: Int) -> AnyPublisher { + status + .map { status in + guard let status = status?.reblog ?? status else { return nil } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + return media[index] + } + .eraseToAnyPublisher() + } + + func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return _handleTableView(tableView, configuration: configuration) + } + + func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return _handleTableView(tableView, configuration: configuration) + } + + private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil } + guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { + return nil + } + let imageViews = cell.mosaicImageViewContainer.imageViews + guard index < imageViews.count else { return nil } + let imageView = imageViews[index] + return UITargetedPreview(view: imageView, parameters: UIPreviewParameters()) + } + + func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + guard let previewableViewController = self as? MediaPreviewableViewController else { return } + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return } + guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return } + let imageViews = cell.mosaicImageViewContainer.imageViews + guard index < imageViews.count else { return } + let imageView = imageViews[index] + + let status = status(for: nil, indexPath: indexPath) + let initialFrame: CGRect? = { + guard let previewViewController = animator.previewViewController else { return nil } + return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController) + }() + animator.preferredCommitStyle = .pop + animator.addCompletion { [weak self] in + guard let self = self else { return } + status + //.delay(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] status in + guard let self = self else { return } + guard let status = (status?.reblog ?? status) else { return } + + let meta = MediaPreviewViewModel.StatusImagePreviewMeta( + statusObjectID: status.objectID, + initialIndex: index, + preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails() + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .mosaic(cell.mosaicImageViewContainer), + previewableViewController: previewableViewController + ) + pushTransitionItem.aspectRatio = { + if let image = imageView.image { + return image.size + } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + let meta = media[index].meta + guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } + return CGSize(width: width, height: height) + }() + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + if let initialFrame = initialFrame { + return initialFrame + } + return imageView.superview!.convert(imageView.frame, to: nil) + }() + pushTransitionItem.image = { + if let image = imageView.image { + return image + } + if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count { + return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image + } + + return nil + }() + let mediaPreviewViewModel = MediaPreviewViewModel( + context: self.context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController)) + } + } + .store(in: &cell.disposeBag) + } + } + + + + +} + +extension UIView { + + // hack to retrieve preview view frame in window + fileprivate static func findContextMenuPreviewFrameInWindow( + previewController: UIViewController + ) -> CGRect? { + guard let window = previewController.view.window else { return nil } + + let targetViews = window.subviews + .map { $0.findSameSize(view: previewController.view) } + .flatMap { $0 } + for targetView in targetViews { + guard let targetViewSuperview = targetView.superview else { continue } + let frame = targetViewSuperview.convert(targetView.frame, to: nil) + guard frame.origin.x > 0, frame.origin.y > 0 else { continue } + return frame + } + + return nil + } + + private func findSameSize(view: UIView) -> [UIView] { + var views: [UIView] = [] + + if view.bounds.size == bounds.size { + views.append(self) + } + + for subview in subviews { + let targetViews = subview.findSameSize(view: view) + views.append(contentsOf: targetViews) + } + + return views + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index d53e8e038..56e9d4746 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -540,7 +540,7 @@ extension StatusProviderFacade { let meta = MediaPreviewViewModel.StatusImagePreviewMeta( statusObjectID: status.objectID, initialIndex: index, - preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image } + preloadThumbnailImages: mosaicImageView.thumbnails() ) let pushTransitionItem = MediaPreviewTransitionItem( source: .mosaic(mosaicImageView), diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index f96998ea6..e418569c1 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -9,12 +9,12 @@ import UIKit import AVKit // Check List Last Updated -// - HomeViewController: 2021/4/13 -// - FavoriteViewController: 2021/4/14 -// - HashtagTimelineViewController: 2021/4/8 -// - UserTimelineViewController: 2021/4/13 -// - ThreadViewController: 2021/4/13 -// * StatusTableViewControllerAspect: 2021/4/12 +// - HomeViewController: 2021/4/30 +// - FavoriteViewController: 2021/4/30 +// - HashtagTimelineViewController: 2021/4/30 +// - UserTimelineViewController: 2021/4/30 +// - ThreadViewController: 2021/4/30 +// * StatusTableViewControllerAspect: 2021/4/30 // (Fake) Aspect protocol to group common protocol extension implementations // Needs update related view controller when aspect interface changes @@ -103,6 +103,38 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } +// [B6] aspectTableView(_:contextMenuConfigurationForRowAt:point:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to display context menu for images + func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return handleTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } +} + +// [B7] aspectTableView(_:contextMenuConfigurationForRowAt:point:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu for images + func aspectTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return handleTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } +} + +// [B8] aspectTableView(_:previewForDismissingContextMenuWithConfiguration:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu for images + func aspectTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return handleTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } +} + +// [B9] aspectTableView(_:willPerformPreviewActionForMenuWith:animator:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu preview action + func aspectTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + handleTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } +} + // MARK: - UITableViewDataSourcePrefetching [C] // [C1] aspectTableView(:prefetchRowsAt) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 7a3404732..638aa7665 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -224,6 +224,23 @@ extension HashtagTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 8932346ed..6db1c26f3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -378,6 +378,23 @@ extension HomeTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index b57be0fb1..5684a6ba4 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -137,9 +137,12 @@ extension MediaPreviewViewController: MediaPreviewingViewController { let safeAreaInsets = previewImageView.safeAreaInsets let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 - return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + let dismissable = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable %s", ((#file as NSString).lastPathComponent), #line, #function, dismissable ? "true" : "false") + return dismissable } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable false", ((#file as NSString).lastPathComponent), #line, #function) return false } @@ -203,12 +206,13 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { 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) + 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) } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index ac4a4c96d..7ac3c2024 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -18,6 +18,8 @@ protocol MediaPreviewImageViewControllerDelegate: AnyObject { final class MediaPreviewImageViewController: UIViewController { var disposeBag = Set() + var observations = Set() + var viewModel: MediaPreviewImageViewModel! weak var delegate: MediaPreviewImageViewControllerDelegate? @@ -56,7 +58,7 @@ extension MediaPreviewImageViewController { previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - + tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:))) longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:))) tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer) @@ -67,39 +69,15 @@ extension MediaPreviewImageViewController { let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) previewImageView.addInteraction(previewImageViewContextMenuInteraction) - switch viewModel.item { - case .status(let meta): -// progressBarView.isHidden = meta.thumbnail != nil - previewImageView.imageView.af.setImage( - withURL: meta.url, - placeholderImage: meta.thumbnail, - filter: nil, - progress: { [weak self] progress in - guard let self = self else { return } - // self.progressBarView.progress.value = CGFloat(progress.fractionCompleted) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %s progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription, progress.fractionCompleted) - }, - imageTransition: .crossDissolve(0.3), - runImageTransitionIfCached: false, - completion: { [weak self] response in - guard let self = self else { return } - switch response.result { - case .success(let image): - //self.progressBarView.isHidden = true - self.previewImageView.imageView.image = image - self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) - case .failure(let error): - // TODO: - break - } - } - ) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setImage url: %s", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription) - case .local(let meta): - // progressBarView.isHidden = true - previewImageView.imageView.image = meta.image - self.previewImageView.setup(image: meta.image, container: self.previewImageView, forceUpdate: true) - } + viewModel.image + .receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state) + .sink { [weak self] image in + guard let self = self else { return } + guard let image = image else { return } + self.previewImageView.imageView.image = image + self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) + } + .store(in: &disposeBag) } } @@ -128,14 +106,16 @@ extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { } 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 + 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 + 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) diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 9215ef61f..6be61dfc4 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -5,20 +5,39 @@ // Created by MainasuK Cirno on 2021-4-28. // +import os.log import UIKit import Combine +import AlamofireImage class MediaPreviewImageViewModel { // input let item: ImagePreviewItem + + // output + let image: CurrentValueSubject init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) + self.image = CurrentValueSubject(meta.thumbnail) + + let url = meta.url + 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) + 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.image.value = image + } + }) } init(meta: LocalImagePreviewMeta) { self.item = .local(meta) + self.image = CurrentValueSubject(meta.image) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 83678cd56..01d76f4b8 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -120,6 +120,22 @@ extension FavoriteViewController: UITableViewDelegate { aspectTableView(tableView, didSelectRowAt: indexPath) } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index d44dd7447..503ce04c3 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -128,6 +128,22 @@ extension UserTimelineViewController: UITableViewDelegate { aspectTableView(tableView, didSelectRowAt: indexPath) } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift new file mode 100644 index 000000000..2a5ba4923 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift @@ -0,0 +1,61 @@ +// +// ContextMenuImagePreviewViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import func AVFoundation.AVMakeRect +import UIKit +import Combine + +final class ContextMenuImagePreviewViewController: UIViewController { + + var disposeBag = Set() + + var viewModel: ContextMenuImagePreviewViewModel! + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + return imageView + }() + +} + +extension ContextMenuImagePreviewViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + imageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: view.topAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + imageView.image = viewModel.thumbnail + + let frame = AVMakeRect(aspectRatio: viewModel.aspectRatio, insideRect: view.bounds) + preferredContentSize = frame.size + + viewModel.url + .sink { [weak self] url in + guard let self = self else { return } + guard let url = url else { return } + self.imageView.af.setImage( + withURL: url, + placeholderImage: self.viewModel.thumbnail, + imageTransition: .crossDissolve(0.2), + runImageTransitionIfCached: true, + completion: nil + ) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift new file mode 100644 index 000000000..f56ff060c --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift @@ -0,0 +1,25 @@ +// +// ContextMenuImagePreviewViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit +import Combine + +final class ContextMenuImagePreviewViewModel { + + var disposeBag = Set() + + // input + let aspectRatio: CGSize + let thumbnail: UIImage? + let url = CurrentValueSubject(nil) + + init(aspectRatio: CGSize, thumbnail: UIImage?) { + self.aspectRatio = aspectRatio + self.thumbnail = thumbnail + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift new file mode 100644 index 000000000..e8e7787f0 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift @@ -0,0 +1,16 @@ +// +// TimelineTableViewCellContextMenuConfiguration.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit + +// note: use subclass configuration not custom NSCopying identifier due to identifier cause crash issue +final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuConfiguration { + + var indexPath: IndexPath? + var index: Int? + +} diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index bec55cd78..ea943fb0e 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -9,14 +9,14 @@ import os.log import func AVFoundation.AVMakeRect import UIKit -protocol MosaicImageViewContainerPresentable: class { +protocol MosaicImageViewContainerPresentable: AnyObject { var mosaicImageViewContainer: MosaicImageViewContainer { get } + var isRevealing: Bool { get } } -protocol MosaicImageViewContainerDelegate: class { +protocol MosaicImageViewContainerDelegate: AnyObject { func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - } final class MosaicImageViewContainer: UIView { @@ -296,7 +296,7 @@ extension MosaicImageViewContainer { } -// FIXME: set imageView source from blurhash and image +// FIXME: refactor blurhash image and preview image extension MosaicImageViewContainer { func setImageViews(alpha: CGFloat) { @@ -313,6 +313,22 @@ extension MosaicImageViewContainer { } } + func thumbnail(at index: Int) -> UIImage? { + guard blurhashOverlayImageViews.count == imageViews.count else { return nil } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + guard index < tuples.count else { return nil } + let tuple = tuples[index] + return tuple.1.image ?? tuple.0.image + } + + func thumbnails() -> [UIImage?] { + guard blurhashOverlayImageViews.count == imageViews.count else { return [] } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in + return imageView.image ?? blurhashOverlayImageView.image + } + } + } extension MosaicImageViewContainer { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 40eb05a58..34367d396 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -194,6 +194,8 @@ final class StatusView: UIView { let activeTextLabel = ActiveLabel(style: .default) private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + var isRevealing = true override init(frame: CGRect) { super.init(frame: frame) @@ -468,6 +470,8 @@ extension StatusView { } func updateRevealContentWarningButton(isRevealing: Bool) { + self.isRevealing = isRevealing + if !isRevealing { let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill") revealContentWarningButton.setImage(image, for: .normal) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d546fea62..5d27a6a93 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,7 +13,7 @@ import CoreData import CoreDataStack import ActiveLabel -protocol StatusTableViewCellDelegate: class { +protocol StatusTableViewCellDelegate: AnyObject { var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } @@ -48,7 +48,7 @@ extension StatusTableViewCellDelegate { } final class StatusTableViewCell: UITableViewCell, StatusCell { - + static let bottomPaddingHeight: CGFloat = 10 weak var delegate: StatusTableViewCellDelegate? @@ -62,7 +62,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { let threadMetaStackView = UIStackView() let threadMetaView = ThreadMetaView() let separatorLine = UIView.separatorLine - + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! @@ -206,6 +206,19 @@ extension StatusTableViewCell { } } +// MARK: - MosaicImageViewContainerPresentable +extension StatusTableViewCell: MosaicImageViewContainerPresentable { + + var mosaicImageViewContainer: MosaicImageViewContainer { + return statusView.statusMosaicImageViewContainer + } + + var isRevealing: Bool { + return statusView.isRevealing + } + +} + // MARK: - UITableViewDelegate extension StatusTableViewCell: UITableViewDelegate { diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 8ca8a3395..6c801ae4f 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -151,6 +151,22 @@ extension ThreadViewController: UITableViewDelegate { } } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index a0bf523f9..74d82badd 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -360,7 +360,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let progress = progressStep(for: translation) let initialSize = transitionItem.initialFrame!.size - assert(initialSize != .zero) + guard initialSize != .zero else { return } + // assert(initialSize != .zero) guard let snapshot = transitionItem.snapshotTransitioning, let finalSize = transitionItem.targetFrame?.size else { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift index c1de3023b..225a83209 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -46,7 +46,7 @@ extension MediaPreviewTransitionController { extension MediaPreviewTransitionController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === panGestureRecognizer { + if gestureRecognizer === panGestureRecognizer || otherGestureRecognizer === panGestureRecognizer { // FIXME: should enable zoom up pan dismiss return false } diff --git a/Mastodon/Vender/CustomScheduler.swift b/Mastodon/Vender/CustomScheduler.swift new file mode 100644 index 000000000..bf87ce053 --- /dev/null +++ b/Mastodon/Vender/CustomScheduler.swift @@ -0,0 +1,50 @@ +// +// CustomScheduler.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import Foundation +import Combine + +// Ref: https://stackoverflow.com/a/59069315/3797903 +struct CustomScheduler: Scheduler { + var runLoop: RunLoop + var modes: [RunLoop.Mode] = [.default] + + func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride, + tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?, + _ action: @escaping () -> Void) -> Cancellable { + let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + return AnyCancellable { + timer.invalidate() + } + } + + func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride, + options: Never?, _ action: @escaping () -> Void) { + let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in + timer.invalidate() + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + } + + func schedule(options: Never?, _ action: @escaping () -> Void) { + runLoop.perform(inModes: modes, block: action) + } + + var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) } + var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) } + + typealias SchedulerTimeType = RunLoop.SchedulerTimeType + typealias SchedulerOptions = Never +}