diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2b99ab..5d06b058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2024.5 (137) +Features/Improvements: +- Improve gallery presentation/dismissal transitions + +Bugfixes: +- Account for bidirectional text in display names +- Fix crash when playing back gifv +- Fix gallery controls not hiding if video loading fails +- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows +- iPadOS: Fix hang when switching accounts + ## 2024.4 (136) Features/Improvements: - Import image description when adding attachments from Photos if possible diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/FallbackGalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/FallbackGalleryContentViewController.swift index 12d28da8..81fd0ebb 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/FallbackGalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/FallbackGalleryContentViewController.swift @@ -82,8 +82,8 @@ public class FallbackGalleryNavigationController: UINavigationController, Galler nil } - public var canAnimateFromSourceView: Bool { - false + public var presentationAnimation: GalleryContentPresentationAnimation { + .fade } } diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/ImageGalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/ImageGalleryContentViewController.swift index 37191dbc..e24895af 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/ImageGalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/ImageGalleryContentViewController.swift @@ -97,6 +97,10 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi return [image] } + public var presentationAnimation: GalleryContentPresentationAnimation { + gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView + } + public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { if #available(iOS 16.0, macCatalyst 17.0, *), let analysisInteraction { diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/LoadingGalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/LoadingGalleryContentViewController.swift index 48509d64..82751d5a 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/LoadingGalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/LoadingGalleryContentViewController.swift @@ -27,8 +27,8 @@ public class LoadingGalleryContentViewController: UIViewController, GalleryConte wrapped?.caption ?? fallbackCaption } - public var canAnimateFromSourceView: Bool { - wrapped?.canAnimateFromSourceView ?? true + public var presentationAnimation: GalleryContentPresentationAnimation { + wrapped?.presentationAnimation ?? .fade } public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) { diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift index 11fc0820..1bce349a 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift @@ -20,6 +20,7 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi private var statusObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation? private var hideControlsWorkItem: DispatchWorkItem? + private var isShowingError = false public init(url: URL, caption: String?) { self.url = url @@ -90,12 +91,15 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi self.container?.setGalleryContentLoading(false) self.showErrorView(error) self.statusObservation = nil + self.overlayVC.setVisible(false) } } }) } private func showErrorView(_ error: any Error) { + isShowingError = true + let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!) image.tintColor = .secondaryLabel image.contentMode = .scaleAspectFit @@ -156,6 +160,10 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi [] } + public var presentationAnimation: GalleryContentPresentationAnimation { + isShowingError ? .fade : .fromSourceViewWithoutSnapshot + } + private lazy var overlayVC = VideoOverlayViewController(player: player) public var contentOverlayAccessoryViewController: UIViewController? { overlayVC @@ -164,7 +172,9 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player) public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { - overlayVC.setVisible(visible) + if !isShowingError { + overlayVC.setVisible(visible) + } if !visible { hideControlsWorkItem?.cancel() @@ -207,9 +217,9 @@ private class PlayerView: UIView { playerLayer.player = player playerLayer.videoGravity = .resizeAspect - presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in + presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in MainActor.assumeIsolated { - self.invalidateIntrinsicContentSize() + self?.invalidateIntrinsicContentSize() } }) } diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift index 980e720d..f77b5570 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift @@ -16,7 +16,7 @@ public protocol GalleryContentViewController: UIViewController { var contentOverlayAccessoryViewController: UIViewController? { get } var bottomControlsAccessoryViewController: UIViewController? { get } var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get } - var canAnimateFromSourceView: Bool { get } + var presentationAnimation: GalleryContentPresentationAnimation { get } var hideControlsOnZoom: Bool { get } func shouldHideControls() -> Bool @@ -38,8 +38,8 @@ public extension GalleryContentViewController { true } - var canAnimateFromSourceView: Bool { - true + var presentationAnimation: GalleryContentPresentationAnimation { + .fromSourceView } var hideControlsOnZoom: Bool { @@ -59,3 +59,9 @@ public extension GalleryContentViewController { func galleryContentWillDisappear() { } } + +public enum GalleryContentPresentationAnimation { + case fade + case fromSourceView + case fromSourceViewWithoutSnapshot +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift index df960cb1..7eeb0cc6 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift @@ -30,12 +30,37 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans let itemViewController = from.currentItemViewController - if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) { + if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) { animateCrossFadeTransition(using: transitionContext) return } let container = transitionContext.containerView + + // Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`) + // is in the window's root presentation. + // But it breaks when the gallery is presented from a sheet-presented VC--in which case + // `to.view` is already in the view hierarchy at this point; and adding it to the + // container causees it to be removed when the transition completes. + if to.view.superview == nil { + to.view.frame = container.bounds + container.addSubview(to.view) + } + + let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot { + nil + } else { + sourceView.snapshotView(afterScreenUpdates: false) + } + if let sourceSnapshot { + let snapshotContainer = sourceView.ancestorForInsertingSnapshot + snapshotContainer.addSubview(sourceSnapshot) + let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView) + sourceSnapshot.frame = sourceFrameInShapshotContainer + sourceSnapshot.layer.opacity = 1 + self.sourceView.layer.opacity = 0 + } + let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView) let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view) @@ -48,38 +73,39 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans .translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY) .scaledBy(x: scale, y: scale) sourceView.transform = sourceToDestTransform + sourceSnapshot?.transform = sourceToDestTransform } else { appliedSourceToDestTransform = false } - // Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`) - // is in the window's root presentation. - // But it breaks when the gallery is presented from a sheet-presented VC--in which case - // `to.view` is already in the view hierarchy at this point; and adding it to the - // container causees it to be removed when the transition completes. - if to.view.superview == nil { - to.view.frame = container.bounds - container.addSubview(to.view) - } - from.view.frame = container.bounds container.addSubview(from.view) - + + let contentContainer = UIView() + contentContainer.layer.masksToBounds = true + contentContainer.frame = destFrameInContainer + container.addSubview(contentContainer) + let content = itemViewController.takeContent() content.view.translatesAutoresizingMaskIntoConstraints = true - content.view.layer.masksToBounds = true - container.addSubview(content.view) - - content.view.frame = destFrameInContainer + content.view.transform = .identity content.view.layer.opacity = 1 - + content.view.frame = contentContainer.bounds + contentContainer.addSubview(content.view) + container.layoutIfNeeded() + // Hide overlaid controls immediately, to prevent the Live Text button's position + // getting caught up in the rest of the animation. + UIView.animate(withDuration: 0.1) { + content.setControlsVisible(false, animated: false, dueToUserInteraction: false) + } + let duration = self.transitionDuration(using: transitionContext) var initialVelocity: CGVector if let interactiveVelocity, let interactiveTranslation, - // very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot + // very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100, sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 { let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX @@ -102,14 +128,34 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans if appliedSourceToDestTransform { self.sourceView.transform = origSourceTransform + sourceSnapshot?.transform = origSourceTransform } - content.view.frame = sourceFrameInContainer - content.view.layer.opacity = 0 + + contentContainer.frame = sourceFrameInContainer + // Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here. + // I guess autoresizing takes care of it? itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false) } + // Delay fading out the content because if it's still big while it's semi-transparent, + // seeing the stuff behind it looks odd. + animator.addAnimations({ + content.view.layer.opacity = 0 + }, delayFactor: 0.35) + + if let sourceSnapshot { + animator.addAnimations({ + self.sourceView.layer.opacity = 1 + sourceSnapshot.layer.opacity = 0 + }, delayFactor: 0.5) + } + animator.addCompletion { _ in + sourceSnapshot?.removeFromSuperview() + + // Having dismissed, we don't need to undo any of the changes to the content VC. + transitionContext.completeTransition(true) } diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift index e7d1ee36..d7153b30 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift @@ -20,6 +20,8 @@ class GalleryDismissInteraction: NSObject { private(set) var dismissVelocity: CGPoint? private(set) var dismissTranslation: CGPoint? + private var cancelAnimator: UIViewPropertyAnimator? + init(viewController: GalleryViewController) { self.viewController = viewController super.init() @@ -38,6 +40,8 @@ class GalleryDismissInteraction: NSObject { content = viewController.currentItemViewController.takeContent() content!.view.translatesAutoresizingMaskIntoConstraints = true content!.view.frame = origContentFrameInGallery! + // Make sure the context remains behind the controls + content!.view.layer.zPosition = -1000 viewController.view.addSubview(content!.view) origControlsVisible = viewController.currentItemViewController.controlsVisible @@ -53,12 +57,42 @@ class GalleryDismissInteraction: NSObject { let translation = recognizer.translation(in: viewController.view) let velocity = recognizer.velocity(in: viewController.view) - dismissVelocity = velocity - dismissTranslation = translation - viewController.dismiss(animated: true) + let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared) + let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared) + + if translationMagnitude < 150 && velocityMagnitude < 500 { + isActive = false + + cancelAnimator?.stopAnimation(true) + + let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero) + cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring) + cancelAnimator!.addAnimations { + self.content!.view.frame = self.origContentFrameInGallery! + self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false) + } + cancelAnimator!.addCompletion { _ in + guard !self.isActive else { + // bail in case the animation finishing raced with the user's interaction + return + } + self.content!.view.layer.zPosition = 0 + self.content!.view.removeFromSuperview() + self.viewController.currentItemViewController.addContent() + self.content = nil + self.origContentFrameInGallery = nil + self.origControlsVisible = nil + } + cancelAnimator!.startAnimation() - // don't unset this until after dismiss is called, so that the dismiss animation controller can read it - isActive = false + } else { + dismissVelocity = velocity + dismissTranslation = translation + viewController.dismiss(animated: true) + + // don't unset this until after dismiss is called, so that the dismiss animation controller can read it + isActive = false + } default: break diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift index 36491852..0b24222c 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift @@ -79,6 +79,10 @@ class GalleryItemViewController: UIViewController { scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.delegate = self + // We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either. + // Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable + // (this is readily observable with tall images on a landscape iPad). + scrollView.contentInsetAdjustmentBehavior = .never view.addSubview(scrollView) diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift index ebd26f72..e508a6f8 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift @@ -25,11 +25,31 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated let itemViewController = to.currentItemViewController - if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions { + if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions { animateCrossFadeTransition(using: transitionContext) return } + // Try to effectively "fade out" anything that's on top of the source view. + // The 0.1 duration makes this happen faster than the rest of the animation, + // and so less noticeable. + let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot { + nil + } else { + sourceView.snapshotView(afterScreenUpdates: false) + } + if let sourceSnapshot { + let snapshotContainer = sourceView.ancestorForInsertingSnapshot + snapshotContainer.addSubview(sourceSnapshot) + let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView) + sourceSnapshot.frame = sourceFrameInShapshotContainer + sourceSnapshot.transform = sourceView.transform + sourceSnapshot.layer.opacity = 0 + UIView.animate(withDuration: 0.1) { + sourceSnapshot.layer.opacity = 1 + } + } + let container = transitionContext.containerView to.view.frame = container.bounds container.addSubview(to.view) @@ -56,21 +76,70 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated sourceToDestTransform = nil } + // Grab these before taking the content out and changing the transform. + let origContentTransform = itemViewController.content.view.transform + let origContentFrame = itemViewController.content.view.frame + + // The content container provides the clipping for the content view, + // which, in case the source/dest aspect ratios don't match, makes + // it look like the content is expanding out from the source rect. + let contentContainer = UIView() + contentContainer.layer.masksToBounds = true + container.insertSubview(contentContainer, belowSubview: to.view) let content = itemViewController.takeContent() content.view.translatesAutoresizingMaskIntoConstraints = true - container.insertSubview(content.view, belowSubview: to.view) + content.view.transform = .identity + // The fade-in makes the aspect ratio handling look a little bit worse, + // but papers over the z-index change and potential corner radius change. + content.view.layer.opacity = 0 + contentContainer.addSubview(content.view) // Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content. let dimmingView = UIView() dimmingView.backgroundColor = .black dimmingView.frame = container.bounds dimmingView.layer.opacity = 0 - container.insertSubview(dimmingView, belowSubview: content.view) + container.insertSubview(dimmingView, belowSubview: contentContainer) to.view.backgroundColor = nil to.view.layer.opacity = 0 - content.view.frame = sourceFrameInContainer - content.view.layer.opacity = 0 + + contentContainer.frame = sourceFrameInContainer + + let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 { + sourceFrameInContainer.width / sourceFrameInContainer.height + } else { + 0 + } + let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 { + destFrameInContainer.width / destFrameInContainer.height + } else { + 0 + } + let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect + if 0.001 < abs(sourceAspectRatio - destAspectRatio) { + // asepct ratios are effectively equal + sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size) + } else if sourceAspectRatio < destAspectRatio { + // source aspect ratio is narrow/taller than dest + let width = sourceFrameInContainer.height * destAspectRatio + sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect( + x: -(width - sourceFrameInContainer.width) / 2, + y: 0, + width: width, + height: sourceFrameInContainer.height + ) + } else { + // source aspect ratio is wider/shorter than dest + let height = sourceFrameInContainer.width / destAspectRatio + sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect( + x: 0, + y: -(height - sourceFrameInContainer.height) / 2, + width: sourceFrameInContainer.width, + height: height + ) + } + content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer container.layoutIfNeeded() @@ -78,8 +147,14 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false) let duration = self.transitionDuration(using: transitionContext) - // rougly equivalent to duration: 0.35, bounce: 0.3 - let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero) + // less bounce on bigger screens + let spring = if UIDevice.current.userInterfaceIdiom == .pad { + // roughly equivalent to duration: 0.35, bounce: 0.2 + UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero) + } else { + // roughly equivalent to duration: 0.35, bounce: 0.3 + UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero) + } let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring) animator.addAnimations { @@ -87,25 +162,35 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated to.view.layer.opacity = 1 - content.view.frame = destFrameInContainer + contentContainer.frame = destFrameInContainer + content.view.frame = contentContainer.bounds content.view.layer.opacity = 1 - + itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false) if let sourceToDestTransform { + sourceSnapshot?.transform = sourceToDestTransform self.sourceView.transform = sourceToDestTransform } } animator.addCompletion { _ in + sourceSnapshot?.removeFromSuperview() + self.sourceView.layer.opacity = 1 + if sourceToDestTransform != nil { + self.sourceView.transform = origSourceTransform + } + + contentContainer.removeFromSuperview() dimmingView.removeFromSuperview() to.view.backgroundColor = .black - if sourceToDestTransform != nil { - self.sourceView.transform = origSourceTransform - } - + // Reset the properties we changed before re-adding the content to the scroll view. + // (I would expect UIScrollView to effectively do this itself, but w/e.) + content.view.transform = origContentTransform + content.view.frame = origContentFrame + itemViewController.addContent() transitionContext.completeTransition(true) diff --git a/Packages/GalleryVC/Sources/GalleryVC/UIView+Utilities.swift b/Packages/GalleryVC/Sources/GalleryVC/UIView+Utilities.swift new file mode 100644 index 00000000..411122be --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/UIView+Utilities.swift @@ -0,0 +1,26 @@ +// +// UIView+Utilities.swift +// GalleryVC +// +// Created by Shadowfacts on 11/24/24. +// + +import UIKit + +extension UIView { + + var ancestorForInsertingSnapshot: UIView { + var view = self + while let superview = view.superview { + if superview.layer.masksToBounds { + return superview + } else if superview is UIScrollView { + return self + } else { + view = superview + } + } + return view + } + +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift index da263257..8a11f139 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift @@ -22,7 +22,12 @@ public struct Emoji: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) self.shortcode = try container.decode(String.self, forKey: .shortcode) - self.url = try container.decode(WebURL.self, forKey: .url) + do { + self.url = try container.decode(WebURL.self, forKey: .url) + } catch { + let s = try? container.decode(String.self, forKey: .url) + throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "")'", underlyingError: error)) + } self.staticURL = try container.decode(WebURL.self, forKey: .staticURL) self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker) self.category = try container.decodeIfPresent(String.self, forKey: .category) diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 0c9746f3..8599ce26 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -375,13 +375,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer, @unchecked Se } } - func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) { - backgroundContext.perform { + func addAll(notifications: [Pachyderm.Notification], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) { + let context = context ?? backgroundContext + context.perform { let statuses = notifications.compactMap { $0.status } let accounts = notifications.map { $0.account } - statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } - accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) } - self.save(context: self.backgroundContext) + statuses.forEach { self.upsert(status: $0, context: context) } + accounts.forEach { self.upsert(account: $0, in: context) } + self.save(context: context) completion?() statuses.forEach { self.statusSubject.send($0.id) } accounts.forEach { self.accountSubject.send($0.id) } diff --git a/Tusker/Screens/Gallery/GifvGalleryContentViewController.swift b/Tusker/Screens/Gallery/GifvGalleryContentViewController.swift index a5d73a1b..efc91b97 100644 --- a/Tusker/Screens/Gallery/GifvGalleryContentViewController.swift +++ b/Tusker/Screens/Gallery/GifvGalleryContentViewController.swift @@ -69,4 +69,8 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont [VideoActivityItemSource(asset: controller.item.asset, url: url)] } + var presentationAnimation: GalleryContentPresentationAnimation { + .fromSourceViewWithoutSnapshot + } + } diff --git a/Tusker/Screens/Main/NewMainTabBarViewController.swift b/Tusker/Screens/Main/NewMainTabBarViewController.swift index 6d989ddc..98912492 100644 --- a/Tusker/Screens/Main/NewMainTabBarViewController.swift +++ b/Tusker/Screens/Main/NewMainTabBarViewController.swift @@ -33,6 +33,13 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController { private var isCompact: Bool? @Box fileprivate var myProfileCell: UIView? private var sidebarTapRecognizer: UITapGestureRecognizer? + + private lazy var fastAccountSwitcherIndicator: UIView = { + let indicator = FastAccountSwitcherIndicatorView() + // need to explicitly set the frame to get it vertically centered + indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize) + return indicator + }() override func viewDidLoad() { super.viewDidLoad() @@ -513,13 +520,6 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate { } } -private var fastAccountSwitcherIndicator: UIView = { - let indicator = FastAccountSwitcherIndicatorView() - // need to explicitly set the frame to get it vertically centered - indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize) - return indicator -}() - @available(iOS 18.0, *) extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate { func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) { diff --git a/Tusker/Screens/Notifications/NotificationLoadingViewController.swift b/Tusker/Screens/Notifications/NotificationLoadingViewController.swift index d1c59acb..82994b91 100644 --- a/Tusker/Screens/Notifications/NotificationLoadingViewController.swift +++ b/Tusker/Screens/Notifications/NotificationLoadingViewController.swift @@ -48,7 +48,9 @@ class NotificationLoadingViewController: UIViewController { do { let (notification, _) = try await mastodonController.run(request) await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addAll(notifications: [notification]) { + let container = mastodonController.persistentContainer + let context = container.viewContext + container.addAll(notifications: [notification], in: context) { continuation.resume() } } diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index 4c565c00..9ec314d9 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -77,11 +77,25 @@ class InstanceTimelineViewController: TimelineViewController { cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent) } + override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + guard browsingEnabled else { + return false + } + return super.collectionView(collectionView, shouldSelectItemAt: indexPath) + } + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard browsingEnabled else { return } super.collectionView(collectionView, didSelectItemAt: indexPath) } + override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard browsingEnabled else { + return nil + } + return super.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPath, point: point) + } + // MARK: Timeline override func handleLoadAllError(_ error: Swift.Error) async { diff --git a/Tusker/Views/Attachments/AttachmentsContainerView.swift b/Tusker/Views/Attachments/AttachmentsContainerView.swift index eb60dce2..22d4b9f6 100644 --- a/Tusker/Views/Attachments/AttachmentsContainerView.swift +++ b/Tusker/Views/Attachments/AttachmentsContainerView.swift @@ -295,7 +295,11 @@ class AttachmentsContainerView: UIView { accessibilityElements.append(moreView) } - self.aspectRatio = aspectRatio + self.aspectRatio = if aspectRatio.isNaN || aspectRatio.isInfinite { + 16/9 + } else { + aspectRatio + } } else { self.isHidden = true } diff --git a/Tusker/Views/Attachments/GifvController.swift b/Tusker/Views/Attachments/GifvController.swift index b89bd2d5..d7992466 100644 --- a/Tusker/Views/Attachments/GifvController.swift +++ b/Tusker/Views/Attachments/GifvController.swift @@ -60,9 +60,9 @@ class GifvController { } private func updatePresentationSizeObservation() { - presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in + presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] item, _ in DispatchQueue.main.async { - self.presentationSizeSubject.send(item.presentationSize) + self?.presentationSizeSubject.send(item.presentationSize) } }) } diff --git a/Version.xcconfig b/Version.xcconfig index abcfb85d..7ed3937e 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -9,8 +9,8 @@ // Configuration settings file format documentation can be found at: // https://help.apple.com/xcode/#/dev745c5c974 -MARKETING_VERSION = 2024.4 -CURRENT_PROJECT_VERSION = 136 +MARKETING_VERSION = 2024.5 +CURRENT_PROJECT_VERSION = 137 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev