diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index 4a7112553..2959c9597 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -187,6 +187,12 @@ "unknown_language": "Unknown", "unknown_provider": "Unknown", "show_original": "Shown Original" + }, + "media": { + "accessibility_label": "%s, attachment %d of %d", + "expand_image_hint": "Expands the image. Double-tap and hold to show actions", + "expand_gif_hint": "Expands the GIF. Double-tap and hold to show actions", + "expand_video_hint": "Shows the video player. Double-tap and hold to show actions" } }, "friendship": { diff --git a/Localization/app.json b/Localization/app.json index 4a7112553..2959c9597 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -187,6 +187,12 @@ "unknown_language": "Unknown", "unknown_provider": "Unknown", "show_original": "Shown Original" + }, + "media": { + "accessibility_label": "%s, attachment %d of %d", + "expand_image_hint": "Expands the image. Double-tap and hold to show actions", + "expand_gif_hint": "Expands the GIF. Double-tap and hold to show actions", + "expand_video_hint": "Shows the video player. Double-tap and hold to show actions" } }, "friendship": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 74b7841a5..0ee9fa7e8 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -98,6 +98,7 @@ 62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */; }; 85904C02293BC0EB0011C817 /* ImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C01293BC0EB0011C817 /* ImageProvider.swift */; }; 85904C04293BC1940011C817 /* URLActivityItemWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */; }; + 85BC11B32932414900E191CD /* AltViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BC11B22932414900E191CD /* AltViewController.swift */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24C97022922F30500BAE8CB /* RefreshControl.swift */; }; D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; }; @@ -620,6 +621,7 @@ 819CEC9DCAD8E8E7BD85A7BB /* Pods-Mastodon.asdk.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk.xcconfig"; sourceTree = ""; }; 85904C01293BC0EB0011C817 /* ImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProvider.swift; sourceTree = ""; }; 85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivityItemWithMetadata.swift; sourceTree = ""; }; + 85BC11B22932414900E191CD /* AltViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltViewController.swift; sourceTree = ""; }; 8850E70A1D5FF51432E43653 /* Pods-Mastodon-MastodonUITests.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; sourceTree = ""; }; 8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release snapshot.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release snapshot.xcconfig"; sourceTree = ""; }; 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; @@ -1975,6 +1977,7 @@ DB6180F026391CAB0018D199 /* Image */, DB6180E1263919780018D199 /* Paging */, DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */, + 85BC11B22932414900E191CD /* AltViewController.swift */, DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */, ); path = MediaPreview; @@ -3535,6 +3538,7 @@ DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, DB3EA8F1281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift in Sources */, + 85BC11B32932414900E191CD /* AltViewController.swift in Sources */, DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */, DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */, DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */, diff --git a/Mastodon/Scene/MediaPreview/AltViewController.swift b/Mastodon/Scene/MediaPreview/AltViewController.swift new file mode 100644 index 000000000..305487397 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/AltViewController.swift @@ -0,0 +1,78 @@ +// +// AltViewController.swift +// Mastodon +// +// Created by Jed Fox on 2022-11-26. +// + +import SwiftUI + +class AltViewController: UIViewController { + private var alt: String + let label = UITextView() + + init(alt: String, sourceView: UIView?) { + self.alt = alt + super.init(nibName: nil, bundle: nil) + self.modalPresentationStyle = .popover + self.popoverPresentationController?.delegate = self + self.popoverPresentationController?.permittedArrowDirections = .up + self.popoverPresentationController?.sourceView = sourceView + self.overrideUserInterfaceStyle = .dark + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + label.translatesAutoresizingMaskIntoConstraints = false + label.textContainer.maximumNumberOfLines = 0 + label.textContainer.lineBreakMode = .byWordWrapping + label.textContainerInset = UIEdgeInsets( + top: 8, + left: 0, + bottom: -label.textContainer.lineFragmentPadding, + right: 0 + ) + label.font = .preferredFont(forTextStyle: .callout) + label.isScrollEnabled = false + label.backgroundColor = .clear + label.isOpaque = false + label.isEditable = false + label.tintColor = .white + label.text = alt + + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate( + NSLayoutConstraint.constraints(withVisualFormat: "V:|-[label]-|", metrics: nil, views: ["label": label]) + ) + NSLayoutConstraint.activate( + NSLayoutConstraint.constraints(withVisualFormat: "H:|-(8)-[label]-(8)-|", metrics: nil, views: ["label": label]) + ) + NSLayoutConstraint.activate([ + label.widthAnchor.constraint(lessThanOrEqualToConstant: 400), + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + UIView.performWithoutAnimation { + preferredContentSize = CGSize( + width: label.intrinsicContentSize.width + 16, + height: label.intrinsicContentSize.height + view.layoutMargins.top + view.layoutMargins.bottom + ) + } + } +} + +// MARK: UIPopoverPresentationControllerDelegate +extension AltViewController: UIPopoverPresentationControllerDelegate { + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + .none + } +} diff --git a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift index 513110d29..68bc0219f 100644 --- a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewController.swift @@ -68,30 +68,21 @@ extension MediaPreviewImageViewController { let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) previewImageView.addInteraction(previewImageViewContextMenuInteraction) - switch viewModel.item { - case .remote(let imageContext): - previewImageView.imageView.accessibilityLabel = imageContext.altText - - if let thumbnail = imageContext.thumbnail { - previewImageView.imageView.image = thumbnail - previewImageView.setup(image: thumbnail, container: self.previewImageView, forceUpdate: true) - } - - previewImageView.imageView.setImage( - url: imageContext.assetURL, - placeholder: imageContext.thumbnail, - scaleToSize: nil - ) { [weak self] image in - guard let self = self else { return } - guard let image = image else { return } - self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) - } - - case .local(let imageContext): - let image = imageContext.image - previewImageView.imageView.image = image - previewImageView.setup(image: image, container: previewImageView, forceUpdate: true) - + previewImageView.imageView.accessibilityLabel = viewModel.item.altText + + if let thumbnail = viewModel.item.thumbnail { + previewImageView.imageView.image = thumbnail + previewImageView.setup(image: thumbnail, container: self.previewImageView, forceUpdate: true) + } + + previewImageView.imageView.setImage( + url: viewModel.item.assetURL, + placeholder: viewModel.item.thumbnail, + scaleToSize: nil + ) { [weak self] image in + guard let self = self else { return } + guard let image = image else { return } + self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) } } diff --git a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift index e82118f78..3a4d9edd2 100644 --- a/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Image/MediaPreviewImageViewModel.swift @@ -30,19 +30,10 @@ class MediaPreviewImageViewModel { extension MediaPreviewImageViewModel { - public enum ImagePreviewItem { - case remote(RemoteImageContext) - case local(LocalImageContext) - } - - public struct RemoteImageContext { + public struct ImagePreviewItem { let assetURL: URL? let thumbnail: UIImage? let altText: String? } - - public struct LocalImageContext { - let image: UIImage - } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 7e7a84358..1200c7d85 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -16,8 +16,6 @@ import MastodonLocalization final class MediaPreviewViewController: UIViewController, NeedsDependency { - static let closeButtonSize = CGSize(width: 30, height: 30) - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -26,24 +24,23 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let pagingViewController = MediaPreviewPagingViewController() - - let closeButtonBackground: UIVisualEffectView = { - let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) - backgroundView.alpha = 0.9 - backgroundView.layer.masksToBounds = true - backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5 - return backgroundView + + let topToolbar: UIStackView = { + let stackView = TouchTransparentStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.alignment = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView }() - - let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) - - let closeButton: UIButton = { - let button = HighlightDimmableButton() - button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) - button.imageView?.tintColor = .label + + let closeButton = HUDButton { button in button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal) - return button - }() + } + + let altButton = HUDButton { button in + button.setTitle("ALT", for: .normal) + } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -67,35 +64,30 @@ extension MediaPreviewViewController { visualEffectView.contentView.addSubview(pagingViewController.view) visualEffectView.pinTo(to: pagingViewController.view) pagingViewController.didMove(toParent: self) - - closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(closeButtonBackground) - NSLayoutConstraint.activate([ - closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12), - closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor) - ]) - closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView) - closeButton.translatesAutoresizingMaskIntoConstraints = false - closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton) + view.addSubview(topToolbar) NSLayoutConstraint.activate([ - closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor), - closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor), - closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor), - closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor), - closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh), - closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh), + topToolbar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12), + topToolbar.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + topToolbar.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), ]) - + + topToolbar.addArrangedSubview(closeButton) + NSLayoutConstraint.activate([ + closeButton.widthAnchor.constraint(equalToConstant: HUDButton.height).priority(.defaultHigh), + ]) + + topToolbar.addArrangedSubview(altButton) + viewModel.mediaPreviewImageViewControllerDelegate = self pagingViewController.interPageSpacing = 10 pagingViewController.delegate = self pagingViewController.dataSource = viewModel - closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) - + closeButton.button.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) + altButton.button.addTarget(self, action: #selector(MediaPreviewViewController.altButtonPressed(_:)), for: .touchUpInside) + // bind view model viewModel.$currentPage .receive(on: DispatchQueue.main) @@ -126,20 +118,34 @@ extension MediaPreviewViewController { let attachment = previewContext.attachments[index] return attachment.kind == .video // not hide buttno for audio }() - self.closeButtonBackground.isHidden = needsHideCloseButton + self.closeButton.isHidden = needsHideCloseButton default: break } } .store(in: &disposeBag) + viewModel.$altText + .receive(on: DispatchQueue.main) + .sink { [weak self] altText in + guard let self else { return } + UIView.animate(withDuration: 0.3) { + if altText == nil { + self.altButton.alpha = 0 + } else { + self.altButton.alpha = 1 + } + } + } + .store(in: &disposeBag) + viewModel.$showingChrome .receive(on: DispatchQueue.main) .removeDuplicates() .sink { [weak self] showingChrome in UIView.animate(withDuration: 0.3) { self?.setNeedsStatusBarAppearanceUpdate() - self?.closeButtonBackground.alpha = showingChrome ? 1 : 0 + self?.topToolbar.alpha = showingChrome ? 1 : 0 } } .store(in: &disposeBag) @@ -170,10 +176,14 @@ extension MediaPreviewViewController { extension MediaPreviewViewController { @objc private func closeButtonPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) dismiss(animated: true, completion: nil) } - + + @objc private func altButtonPressed(_ sender: UIButton) { + guard let alt = viewModel.altText else { return } + present(AltViewController(alt: alt, sourceView: sender), animated: true) + } + } // MARK: - MediaPreviewingViewController @@ -270,19 +280,8 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { ) { switch action { case .savePhoto: - let _savePublisher: AnyPublisher? = { - switch viewController.viewModel.item { - case .remote(let previewContext): - guard let assetURL = previewContext.assetURL else { return nil } - return context.photoLibraryService.save(imageSource: .url(assetURL)) - case .local(let previewContext): - return context.photoLibraryService.save(imageSource: .image(previewContext.image)) - } - }() - guard let savePublisher = _savePublisher else { - return - } - savePublisher + guard let assetURL = viewController.viewModel.item.assetURL else { return } + context.photoLibraryService.save(imageSource: .url(assetURL)) .sink { [weak self] completion in guard let self = self else { return } switch completion { @@ -306,20 +305,9 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { } .store(in: &context.disposeBag) case .copyPhoto: - let _copyPublisher: AnyPublisher? = { - switch viewController.viewModel.item { - case .remote(let previewContext): - guard let assetURL = previewContext.assetURL else { return nil } - return context.photoLibraryService.copy(imageSource: .url(assetURL)) - case .local(let previewContext): - return context.photoLibraryService.copy(imageSource: .image(previewContext.image)) - } - }() - guard let copyPublisher = _copyPublisher else { - return - } + guard let assetURL = viewController.viewModel.item.assetURL else { return } - copyPublisher + context.photoLibraryService.copy(imageSource: .url(assetURL)) .sink { completion in switch completion { case .failure(let error): @@ -338,13 +326,8 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { let activityViewController = UIActivityViewController( activityItems: { var activityItems: [Any] = [] - switch viewController.viewModel.item { - case .remote(let previewContext): - if let assetURL = previewContext.assetURL { - activityItems.append(assetURL) - } - case .local(let previewContext): - activityItems.append(previewContext.image) + if let assetURL = viewController.viewModel.item.assetURL { + activityItems.append(assetURL) } return activityItems }(), diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 3f60b19e5..a6b604d6f 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -27,9 +27,10 @@ final class MediaPreviewViewModel: NSObject { @Published var currentPage: Int @Published var showingChrome = true - + @Published var altText: String? + // output - let viewControllers: [UIViewController] + let viewControllers: [MediaPreviewPage] private var disposeBag: Set = [] @@ -42,8 +43,11 @@ final class MediaPreviewViewModel: NSObject { self.item = item var currentPage = 0 var viewControllers: [MediaPreviewPage] = [] + var getAltText = { (page: Int) -> String? in nil } switch item { case .attachment(let previewContext): + getAltText = { previewContext.attachments[$0].altDescription } + currentPage = previewContext.initialIndex for (i, attachment) in previewContext.attachments.enumerated() { switch attachment.kind { @@ -51,11 +55,11 @@ final class MediaPreviewViewModel: NSObject { let viewController = MediaPreviewImageViewController() let viewModel = MediaPreviewImageViewModel( context: context, - item: .remote(.init( + item: .init( assetURL: attachment.assetURL.flatMap { URL(string: $0) }, thumbnail: previewContext.thumbnail(at: i), altText: attachment.altDescription - )) + ) ) viewController.viewModel = viewModel viewControllers.append(viewController) @@ -65,7 +69,8 @@ final class MediaPreviewViewModel: NSObject { context: context, item: .gif(.init( assetURL: attachment.assetURL.flatMap { URL(string: $0) }, - previewURL: attachment.previewURL.flatMap { URL(string: $0) } + previewURL: attachment.previewURL.flatMap { URL(string: $0) }, + altText: attachment.altDescription )) ) viewController.viewModel = viewModel @@ -76,7 +81,8 @@ final class MediaPreviewViewModel: NSObject { context: context, item: .video(.init( assetURL: attachment.assetURL.flatMap { URL(string: $0) }, - previewURL: attachment.previewURL.flatMap { URL(string: $0) } + previewURL: attachment.previewURL.flatMap { URL(string: $0) }, + altText: attachment.altDescription )) ) viewController.viewModel = viewModel @@ -87,11 +93,11 @@ final class MediaPreviewViewModel: NSObject { let viewController = MediaPreviewImageViewController() let viewModel = MediaPreviewImageViewModel( context: context, - item: .remote(.init( + item: .init( assetURL: previewContext.assetURL.flatMap { URL(string: $0) }, thumbnail: previewContext.thumbnail, altText: nil - )) + ) ) viewController.viewModel = viewModel viewControllers.append(viewController) @@ -99,11 +105,11 @@ final class MediaPreviewViewModel: NSObject { let viewController = MediaPreviewImageViewController() let viewModel = MediaPreviewImageViewModel( context: context, - item: .remote(.init( + item: .init( assetURL: previewContext.assetURL.flatMap { URL(string: $0) }, thumbnail: previewContext.thumbnail, altText: nil - )) + ) ) viewController.viewModel = viewModel viewControllers.append(viewController) @@ -114,6 +120,10 @@ final class MediaPreviewViewModel: NSObject { self.transitionItem = transitionItem super.init() + self.$currentPage + .map(getAltText) + .assign(to: &$altText) + for viewController in viewControllers { self.$showingChrome .sink { [weak viewController] showingChrome in diff --git a/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift index 97e5f955b..a6542d464 100644 --- a/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Video/MediaPreviewVideoViewModel.swift @@ -130,12 +130,14 @@ extension MediaPreviewVideoViewModel { struct RemoteVideoContext { let assetURL: URL? let previewURL: URL? + let altText: String? // let thumbnail: UIImage? } struct RemoteGIFContext { let assetURL: URL? let previewURL: URL? + let altText: String? } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 23a3664f3..4a88236e9 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -81,8 +81,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { transitionItem.transitionView = transitionImageView transitionContext.containerView.addSubview(transitionImageView) - toVC.closeButtonBackground.alpha = 0 - + toVC.topToolbar.alpha = 0 + if UIAccessibility.isReduceTransparencyEnabled { toVC.visualEffectView.alpha = 0 } @@ -101,7 +101,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { toVC.pagingViewController.view.alpha = 1 transitionImageView.removeFromSuperview() UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { - toVC.closeButtonBackground.alpha = 1 + toVC.topToolbar.alpha = 1 } transitionContext.completeTransition(position == .end) } @@ -138,13 +138,13 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } } - // update close button + // update top toolbar UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { - fromVC.closeButtonBackground.alpha = 0 + fromVC.topToolbar.alpha = 0 } animator.addCompletion { position in UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { - fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 + fromVC.topToolbar.alpha = position == .end ? 0 : 1 } } @@ -202,7 +202,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { mediaPreviewTransitionContext.snapshot.contentMode = .scaleAspectFill mediaPreviewTransitionContext.snapshot.clipsToBounds = true transitionMaskView.addSubview(mediaPreviewTransitionContext.snapshot) - fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground) + fromVC.view.bringSubviewToFront(fromVC.topToolbar) transitionItem.transitionView = mediaPreviewTransitionContext.transitionView transitionItem.snapshotTransitioning = mediaPreviewTransitionContext.snapshot diff --git a/MastodonSDK/Sources/MastodonExtension/UIEdgeInsets.swift b/MastodonSDK/Sources/MastodonExtension/UIEdgeInsets.swift new file mode 100644 index 000000000..8436ff5d2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/UIEdgeInsets.swift @@ -0,0 +1,14 @@ +// +// UIEdgeInsets.swift +// +// +// Created by Jed Fox on 2022-11-24. +// + +import UIKit + +extension UIEdgeInsets { + public static func constant(_ offset: CGFloat) -> Self { + UIEdgeInsets(top: offset, left: offset, bottom: offset, right: offset) + } +} diff --git a/MastodonSDK/Sources/MastodonExtension/UIView.swift b/MastodonSDK/Sources/MastodonExtension/UIView.swift index bfa253680..84f87eb20 100644 --- a/MastodonSDK/Sources/MastodonExtension/UIView.swift +++ b/MastodonSDK/Sources/MastodonExtension/UIView.swift @@ -48,20 +48,19 @@ extension UIView { } public extension UIView { - @discardableResult - func pinToParent() -> [NSLayoutConstraint] { - pinTo(to: self.superview) + func pinToParent(padding: UIEdgeInsets = .zero) -> [NSLayoutConstraint] { + pinTo(to: self.superview, padding: padding) } @discardableResult - func pinTo(to view: UIView?) -> [NSLayoutConstraint] { + func pinTo(to view: UIView?, padding: UIEdgeInsets = .zero) -> [NSLayoutConstraint] { guard let pinToView = view else { return [] } let constraints = [ - topAnchor.constraint(equalTo: pinToView.topAnchor), - leadingAnchor.constraint(equalTo: pinToView.leadingAnchor), - trailingAnchor.constraint(equalTo: pinToView.trailingAnchor), - bottomAnchor.constraint(equalTo: pinToView.bottomAnchor), + topAnchor.constraint(equalTo: pinToView.topAnchor, constant: padding.top), + leadingAnchor.constraint(equalTo: pinToView.leadingAnchor, constant: padding.left), + trailingAnchor.constraint(equalTo: pinToView.trailingAnchor, constant: -padding.right), + bottomAnchor.constraint(equalTo: pinToView.bottomAnchor, constant: -padding.bottom), ] NSLayoutConstraint.activate(constraints) return constraints diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 658f18433..e4bc9b418 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -340,6 +340,18 @@ public enum L10n { /// Undo reblog public static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog", fallback: "Undo reblog") } + public enum Media { + /// %@, attachment %d of %d + public static func accessibilityLabel(_ p1: Any, _ p2: Int, _ p3: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Media.AccessibilityLabel", String(describing: p1), p2, p3, fallback: "%@, attachment %d of %d") + } + /// Expands the GIF. Double-tap and hold to show actions + public static let expandGifHint = L10n.tr("Localizable", "Common.Controls.Status.Media.ExpandGifHint", fallback: "Expands the GIF. Double-tap and hold to show actions") + /// Expands the image. Double-tap and hold to show actions + public static let expandImageHint = L10n.tr("Localizable", "Common.Controls.Status.Media.ExpandImageHint", fallback: "Expands the image. Double-tap and hold to show actions") + /// Shows the video player. Double-tap and hold to show actions + public static let expandVideoHint = L10n.tr("Localizable", "Common.Controls.Status.Media.ExpandVideoHint", fallback: "Shows the video player. Double-tap and hold to show actions") + } public enum MetaEntity { /// Email address: %@ public static func email(_ p1: Any) -> String { diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index aab9bd6cd..5280f2dcd 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -116,6 +116,10 @@ Please check your internet connection."; "Common.Controls.Status.ContentWarning" = "Content Warning"; "Common.Controls.Status.LinkViaUser" = "%@ via %@"; "Common.Controls.Status.LoadEmbed" = "Load Embed"; +"Common.Controls.Status.Media.AccessibilityLabel" = "%@, attachment %d of %d"; +"Common.Controls.Status.Media.ExpandGifHint" = "Expands the GIF. Double-tap and hold to show actions"; +"Common.Controls.Status.Media.ExpandImageHint" = "Expands the image. Double-tap and hold to show actions"; +"Common.Controls.Status.Media.ExpandVideoHint" = "Shows the video player. Double-tap and hold to show actions"; "Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal"; "Common.Controls.Status.MetaEntity.Email" = "Email address: %@"; "Common.Controls.Status.MetaEntity.Hashtag" = "Hashtag: %@"; diff --git a/MastodonSDK/Sources/MastodonUI/Extension/CGSize+Hashable.swift b/MastodonSDK/Sources/MastodonUI/Extension/CGSize+Hashable.swift new file mode 100644 index 000000000..0578d543e --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/CGSize+Hashable.swift @@ -0,0 +1,15 @@ +// +// CGSize.swift +// +// +// Created by Jed Fox on 2022-12-20. +// + +import Foundation + +extension CGSize: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(width) + hasher.combine(height) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Button/HUDButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/HUDButton.swift new file mode 100644 index 000000000..566100bb6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Button/HUDButton.swift @@ -0,0 +1,77 @@ +// +// HUDButton.swift +// Mastodon +// +// Created by Jed Fox on 2022-11-24. +// + +import UIKit + +public class HUDButton: UIView { + + public static let height: CGFloat = 30 + + let background: UIVisualEffectView = { + let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + backgroundView.alpha = 0.9 + backgroundView.layer.masksToBounds = true + backgroundView.layer.cornerRadius = HUDButton.height * 0.5 + return backgroundView + }() + + let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) + + public let button: UIButton = { + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + button.contentEdgeInsets = .constant(7) + button.imageView?.tintColor = .label + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)) + return button + }() + + public init(configure: (UIButton) -> Void) { + super.init(frame: .zero) + + configure(button) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func _init() { + translatesAutoresizingMaskIntoConstraints = false + background.translatesAutoresizingMaskIntoConstraints = false + addSubview(background) + background.pinToParent() + vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + background.contentView.addSubview(vibrancyView) + + button.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.contentView.addSubview(button) + button.pinToParent() + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: HUDButton.height).priority(.defaultHigh), + ]) + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)) + } + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + button.point(inside: button.convert(point, from: self), with: event) + } + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.point(inside: point, with: event) { + return button + } else { + return nil + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/TouchTransparentStackView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/TouchTransparentStackView.swift new file mode 100644 index 000000000..e519c0cf7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/TouchTransparentStackView.swift @@ -0,0 +1,26 @@ +// +// TouchTransparentStackView.swift +// +// +// Created by Jed Fox on 2022-12-21. +// + +import UIKit + +/// A subclass of `UIStackView` that allows touches that aren’t captured by any +/// of its subviews to pass through to views beneath this view in the Z-order. +public class TouchTransparentStackView: UIStackView { + // allow subview hit boxes to grow outside of this view’s bounds + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + subviews.contains { $0.point(inside: $0.convert(point, from: self), with: event) } + } + + // allow taps on blank areas to pass through + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let view = super.hitTest(point, with: event) + if view == self { + return nil + } + return view + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift new file mode 100644 index 000000000..fec17f1ee --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift @@ -0,0 +1,80 @@ +// +// MediaAltTextOverlay.swift +// +// +// Created by Jed Fox on 2022-12-20. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct MediaAltTextOverlay: View { + var altDescription: String? + + @State private var showingAlt = false + @Namespace private var namespace + + var body: some View { + GeometryReader { geom in + ZStack { + if let altDescription { + if showingAlt { + HStack(alignment: .top) { + Text(altDescription) + Spacer() + Button(action: { showingAlt = false }) { + Image(systemName: "xmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 20, height: 20) + } + } + .padding(8) + .matchedGeometryEffect(id: "background", in: namespace, properties: .position) + .transition( + .scale(scale: 0.2, anchor: .bottomLeading) + .combined(with: .opacity) + ) + } else { + Button("ALT") { showingAlt = true } + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .matchedGeometryEffect(id: "background", in: namespace, properties: .position) + .transition( + .scale(scale: 3, anchor: .trailing) + .combined(with: .opacity) + ) + } + } + } + .foregroundColor(.white) + .tint(.white) + .background(Color.black.opacity(0.85)) + .cornerRadius(4) + .overlay( + .white.opacity(0.5), + in: RoundedRectangle(cornerRadius: 4) + .inset(by: -0.5) + .stroke(lineWidth: 0.5) + ) + .animation(.spring(response: 0.3), value: showingAlt) + .frame(width: geom.size.width, height: geom.size.height, alignment: .bottomLeading) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .onChange(of: altDescription) { _ in + showingAlt = false + } + } +} + +@available(iOS 15.0, *) +struct MediaAltTextOverlay_Previews: PreviewProvider { + static var previews: some View { + MediaAltTextOverlay(altDescription: "Hello, world!") + .frame(height: 300) + .background(Color.gray) + .previewLayout(.sizeThatFits) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index 438baff7e..05c8eee14 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -21,6 +21,8 @@ extension MediaView { public let info: Info public let blurhash: String? + public let index: Int + public let total: Int @Published public var isReveal = true @Published public var previewImage: UIImage? @@ -29,10 +31,14 @@ extension MediaView { public init( info: MediaView.Configuration.Info, - blurhash: String? + blurhash: String?, + index: Int, + total: Int ) { self.info = info self.blurhash = blurhash + self.index = index + self.total = total } public var aspectRadio: CGSize { @@ -101,19 +107,16 @@ extension MediaView.Configuration { public struct ImageInfo: Hashable { public let aspectRadio: CGSize public let assetURL: String? + public let altDescription: String? public init( aspectRadio: CGSize, - assetURL: String? + assetURL: String?, + altDescription: String? ) { self.aspectRadio = aspectRadio self.assetURL = assetURL - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(aspectRadio.width) - hasher.combine(aspectRadio.height) - assetURL.flatMap { hasher.combine($0) } + self.altDescription = altDescription } } @@ -121,26 +124,21 @@ extension MediaView.Configuration { public let aspectRadio: CGSize public let assetURL: String? public let previewURL: String? + public let altDescription: String? public let durationMS: Int? public init( aspectRadio: CGSize, assetURL: String?, previewURL: String?, + altDescription: String?, durationMS: Int? ) { self.aspectRadio = aspectRadio self.assetURL = assetURL self.previewURL = previewURL self.durationMS = durationMS - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(aspectRadio.width) - hasher.combine(aspectRadio.height) - assetURL.flatMap { hasher.combine($0) } - previewURL.flatMap { hasher.combine($0) } - durationMS.flatMap { hasher.combine($0) } + self.altDescription = altDescription } } @@ -187,41 +185,51 @@ extension MediaView { aspectRadio: attachment.size, assetURL: attachment.assetURL, previewURL: attachment.previewURL, + altDescription: attachment.altDescription, durationMS: attachment.durationMS ) } let status = status.reblog ?? status let attachments = status.attachments - let configurations = attachments.map { attachment -> MediaView.Configuration in + let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in let configuration: MediaView.Configuration = { switch attachment.kind { case .image: let info = MediaView.Configuration.ImageInfo( aspectRadio: attachment.size, - assetURL: attachment.assetURL + assetURL: attachment.assetURL, + altDescription: attachment.altDescription ) return .init( info: .image(info: info), - blurhash: attachment.blurhash + blurhash: attachment.blurhash, + index: idx, + total: attachments.count ) case .video: let info = videoInfo(from: attachment) return .init( info: .video(info: info), - blurhash: attachment.blurhash + blurhash: attachment.blurhash, + index: idx, + total: attachments.count ) case .gifv: let info = videoInfo(from: attachment) return .init( info: .gif(info: info), - blurhash: attachment.blurhash + blurhash: attachment.blurhash, + index: idx, + total: attachments.count ) case .audio: let info = videoInfo(from: attachment) return .init( info: .video(info: info), - blurhash: attachment.blurhash + blurhash: attachment.blurhash, + index: idx, + total: attachments.count ) } // end switch }() diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift index 54560a8ce..a140d9737 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -10,18 +10,14 @@ import AVKit import UIKit import Combine import AlamofireImage +import SwiftUI +import MastodonLocalization public final class MediaView: UIView { var _disposeBag = Set() public static let cornerRadius: CGFloat = 0 - public static let durationFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.zeroFormattingBehavior = .pad - formatter.allowedUnits = [.minute, .second] - return formatter - }() public static let placeholderImage = UIImage.placeholder(color: .systemGray6) public let container = TouchBlockingView() @@ -77,6 +73,20 @@ public final class MediaView: UIView { return label }() + let _altViewController: UIViewController! = { + if #available(iOS 15.0, *) { + let vc = UIHostingController(rootView: MediaAltTextOverlay()) + vc.view.backgroundColor = .clear + return vc + } else { + return nil + } + }() + @available(iOS 15.0, *) + var altViewController: UIHostingController { + _altViewController as! UIHostingController + } + public override init(frame: CGRect) { super.init(frame: frame) _init() @@ -118,18 +128,18 @@ extension MediaView { case .image(let info): layoutImage() bindImage(configuration: configuration, info: info) - accessibilityLabel = "Show image" // TODO: i18n + accessibilityHint = L10n.Common.Controls.Status.Media.expandImageHint case .gif(let info): layoutGIF() bindGIF(configuration: configuration, info: info) - accessibilityLabel = "Show GIF" // TODO: i18n + accessibilityHint = L10n.Common.Controls.Status.Media.expandGifHint case .video(let info): layoutVideo() bindVideo(configuration: configuration, info: info) - accessibilityLabel = "Show video player" // TODO: i18n + accessibilityHint = L10n.Common.Controls.Status.Media.expandVideoHint } - accessibilityHint = "Tap then hold to show menu" // TODO: i18n + accessibilityTraits.insert([.button, .image]) layoutBlurhash() bindBlurhash(configuration: configuration) @@ -139,6 +149,7 @@ extension MediaView { imageView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(imageView) imageView.pinToParent() + layoutAlt() } private func bindImage(configuration: Configuration, info: Configuration.ImageInfo) { @@ -157,8 +168,10 @@ extension MediaView { self.imageView.image = image } .store(in: &configuration.disposeBag) + + bindAlt(configuration: configuration, altDescription: info.altDescription) } - + private func layoutGIF() { // use view controller as View here playerViewController.view.translatesAutoresizingMaskIntoConstraints = false @@ -167,6 +180,8 @@ extension MediaView { setupIndicatorViewHierarchy() playerIndicatorLabel.attributedText = NSAttributedString(string: "GIF") + + layoutAlt() } private func bindGIF(configuration: Configuration, info: Configuration.VideoInfo) { @@ -177,6 +192,8 @@ extension MediaView { // auto play for GIF player.play() + + bindAlt(configuration: configuration, altDescription: info.altDescription) } private func layoutVideo() { @@ -195,11 +212,27 @@ extension MediaView { private func bindVideo(configuration: Configuration, info: Configuration.VideoInfo) { let imageInfo = Configuration.ImageInfo( aspectRadio: info.aspectRadio, - assetURL: info.previewURL + assetURL: info.previewURL, + altDescription: info.altDescription ) bindImage(configuration: configuration, info: imageInfo) } + private func bindAlt(configuration: Configuration, altDescription: String?) { + if configuration.total > 1 { + accessibilityLabel = L10n.Common.Controls.Status.Media.accessibilityLabel( + altDescription ?? "", + configuration.index + 1, + configuration.total + ) + } else { + accessibilityLabel = altDescription + } + if #available(iOS 15.0, *) { + altViewController.rootView.altDescription = altDescription + } + } + private func layoutBlurhash() { blurhashImageView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(blurhashImageView) @@ -228,6 +261,14 @@ extension MediaView { .store(in: &_disposeBag) } + private func layoutAlt() { + if #available(iOS 15.0, *) { + altViewController.view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(altViewController.view) + altViewController.view.pinToParent() + } + } + public func prepareForReuse() { _disposeBag.removeAll() @@ -263,6 +304,10 @@ extension MediaView { container.removeFromSuperview() container.removeConstraints(container.constraints) + if #available(iOS 15.0, *) { + altViewController.rootView.altDescription = nil + } + // reset configuration configuration = nil } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift index 7f44232aa..8403be756 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift @@ -39,9 +39,12 @@ extension NewsView { let configuration = MediaView.Configuration( info: .image(info: .init( aspectRadio: CGSize(width: link.width, height: link.height), - assetURL: link.image + assetURL: link.image, + altDescription: nil )), - blurhash: link.blurhash + blurhash: link.blurhash, + index: 1, + total: 1 ) imageView.setup(configuration: configuration)