diff --git a/.gitignore b/.gitignore index 51ba722ba..a766fc629 100644 --- a/.gitignore +++ b/.gitignore @@ -119,5 +119,5 @@ xcuserdata # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods -Mastodon/Localization/StringsConvertor/input -Mastodon/Localization/StringsConvertor/output \ No newline at end of file +Localization/StringsConvertor/input +Localization/StringsConvertor/output \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index 56a54a7f4..f2c2839a6 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -29,8 +29,9 @@ }, "status": { "user_boosted": "%s boosted", - "content_warning": "content warning", - "show_post": "Show Post" + "show_post": "Show Post", + "status_content_warning": "content warning", + "media_content_warning": "Tap to reveal that may be sensitive" }, "timeline": { "load_more": "Load More" diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 2ab198226..c6a182b4d 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -26,24 +26,30 @@ enum Item { protocol StatusContentWarningAttribute { var isStatusTextSensitive: Bool { get set } + var isStatusSensitive: Bool { get set } } extension Item { class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { - var isStatusTextSensitive: Bool = false + var isStatusTextSensitive: Bool + var isStatusSensitive: Bool public init( - isStatusTextSensitive: Bool + isStatusTextSensitive: Bool, + isStatusSensitive: Bool ) { self.isStatusTextSensitive = isStatusTextSensitive + self.isStatusSensitive = isStatusSensitive } static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool { - return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive + return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && + lhs.isStatusSensitive == rhs.isStatusSensitive } func hash(into hasher: inout Hasher) { hasher.combine(isStatusTextSensitive) + hasher.combine(isStatusSensitive) } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 727f4074f..4fac88b4c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -94,14 +94,18 @@ extension StatusSection { // set text cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) - // set content warning - let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? (toot.reblog ?? toot).sensitive + // set status text content warning + let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" + let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) - cell.statusView.contentWarningTitle.text = (toot.reblog ?? toot).spoilerText.flatMap { spoilerText in - guard !spoilerText.isEmpty else { return nil } - return L10n.Common.Controls.Status.contentWarning + ": \(spoilerText)" - } ?? L10n.Common.Controls.Status.contentWarning + cell.statusView.contentWarningTitle.text = { + if spoilerText.isEmpty { + return L10n.Common.Controls.Status.statusContentWarning + } else { + return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)" + } + }() // prepare media attachments let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } @@ -146,6 +150,9 @@ extension StatusSection { } } cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty + let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive + cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // toolbar let replyCountTitle: String = { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index fa641fbbb..5c6f58bbf 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -56,10 +56,12 @@ internal enum L10n { internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") } internal enum Status { - /// content warning - internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") + /// Tap to reveal that may be sensitive + internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") /// Show Post internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") + /// content warning + internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") /// %@ boosted internal static func userBoosted(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift index 9c6127b08..336434ff0 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift @@ -43,3 +43,39 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + + } + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + item(for: cell, indexPath: nil) + .receive(on: DispatchQueue.main) + .sink { [weak self] item in + guard let _ = self else { return } + guard let item = item else { return } + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusSensitive = false + case .toot(_, let attribute): + attribute.isStatusSensitive = false + default: + return + } + + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + UIView.animate(withDuration: 0.33) { + cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil + cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0 + } completion: { _ in + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } + .store(in: &cell.disposeBag) + } + +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index efb3fed70..08a8d8293 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -15,8 +15,9 @@ "Common.Controls.Actions.SignIn" = "Sign in"; "Common.Controls.Actions.SignUp" = "Sign up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; -"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 8f74b81a4..d5345de4f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -83,7 +83,12 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { var newTimelineItems: [Item] = [] for (i, timelineIndex) in timelineIndexes.enumerated() { - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: timelineIndex.toot.sensitive) + let toot = timelineIndex.toot.reblog ?? timelineIndex.toot + let isStatusTextSensitive: Bool = { + guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 5a5001c19..dd5ffc84e 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -13,7 +13,7 @@ import GameplayKit import os.log import UIKit -final class PublicTimelineViewController: UIViewController, NeedsDependency, StatusTableViewCellDelegate { +final class PublicTimelineViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -203,3 +203,6 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat } } } + +// MARK: - StatusTableViewCellDelegate +extension PublicTimelineViewController: StatusTableViewCellDelegate { } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 26638578f..f9c92fa0f 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -58,7 +58,12 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { var items = [Item]() for (_, toot) in indexTootTuples { - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: toot.sensitive) + let targetToot = toot.reblog ?? toot + let isStatusTextSensitive: Bool = { + guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) if tootIDsWhichHasGap.contains(toot.id) { items.append(Item.publicMiddleLoader(tootID: toot.id)) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 14e2012e0..5240d4e2c 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -13,18 +13,21 @@ protocol MosaicImageViewContainerPresentable: class { var mosaicImageViewContainer: MosaicImageViewContainer { get } } -protocol MosaicImageViewDelegate: class { +protocol MosaicImageViewContainerDelegate: class { func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + } final class MosaicImageViewContainer: UIView { static let cornerRadius: CGFloat = 4 + static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) - weak var delegate: MosaicImageViewDelegate? + weak var delegate: MosaicImageViewContainerDelegate? let container = UIStackView() - var imageViews = [UIImageView]() { + var imageViews: [UIImageView] = [] { didSet { imageViews.forEach { imageView in imageView.isUserInteractionEnabled = true @@ -34,7 +37,16 @@ final class MosaicImageViewContainer: UIView { } } } - + let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect) + let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect)) + let contentWarningLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + label.text = L10n.Common.Controls.Status.mediaContentWarning + label.textAlignment = .center + return label + }() + private var containerHeightLayoutConstraint: NSLayoutConstraint! override init(frame: CGRect) { @@ -53,6 +65,8 @@ extension MosaicImageViewContainer { private func _init() { container.translatesAutoresizingMaskIntoConstraints = false + container.axis = .horizontal + container.distribution = .fillEqually addSubview(container) containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) NSLayoutConstraint.activate([ @@ -63,8 +77,32 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint ]) - container.axis = .horizontal - container.distribution = .fillEqually + // add blur visual effect view in the setup method + blurVisualEffectView.layer.masksToBounds = true + blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius + blurVisualEffectView.layer.cornerCurve = .continuous + + vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) + NSLayoutConstraint.activate([ + vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor), + vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor), + vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor), + vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor), + ]) + + contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false + vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel) + NSLayoutConstraint.activate([ + contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor), + contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), + contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), + ]) + + blurVisualEffectView.isUserInteractionEnabled = true + let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer + tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:))) + blurVisualEffectView.addGestureRecognizer(tapGesture) } } @@ -79,6 +117,9 @@ extension MosaicImageViewContainer { container.subviews.forEach { subview in subview.removeFromSuperview() } + blurVisualEffectView.removeFromSuperview() + blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect + vibrancyVisualEffectView.alpha = 1.0 imageViews = [] container.spacing = 1 @@ -100,6 +141,7 @@ extension MosaicImageViewContainer { imageViews.append(imageView) imageView.layer.masksToBounds = true imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius + imageView.layer.cornerCurve = .continuous imageView.contentMode = .scaleAspectFill imageView.translatesAutoresizingMaskIntoConstraints = false @@ -112,6 +154,15 @@ extension MosaicImageViewContainer { ]) containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true + + blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurVisualEffectView) + NSLayoutConstraint.activate([ + blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor), + blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), + blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), + blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), + ]) return imageView } @@ -191,18 +242,34 @@ extension MosaicImageViewContainer { } } + blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurVisualEffectView) + NSLayoutConstraint.activate([ + blurVisualEffectView.topAnchor.constraint(equalTo: container.topAnchor), + blurVisualEffectView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + blurVisualEffectView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + blurVisualEffectView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return imageViews } + } extension MosaicImageViewContainer { - + + @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.mosaicImageViewContainer(self, didTapContentWarningVisualEffectView: blurVisualEffectView) + } + @objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { guard let imageView = sender.view as? UIImageView else { return } guard let index = imageViews.firstIndex(of: imageView) else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: tap photo at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) delegate?.mosaicImageViewContainer(self, didTapImageView: imageView, atIndex: index) } + } #if DEBUG && canImport(SwiftUI) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2d8d966b9..be754ed86 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -89,7 +89,7 @@ final class StatusView: UIView { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Common.Controls.Status.contentWarning + label.text = L10n.Common.Controls.Status.statusContentWarning return label }() let contentWarningActionButton: UIButton = { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 758beed0c..572f23e01 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -14,6 +14,9 @@ import Combine protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + } final class StatusTableViewCell: UITableViewCell { @@ -79,10 +82,11 @@ extension StatusTableViewCell { bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh), ]) + bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color statusView.delegate = self + statusView.statusMosaicImageView.delegate = self statusView.actionToolbarContainer.delegate = self - bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color } } @@ -94,6 +98,19 @@ extension StatusTableViewCell: StatusViewDelegate { } } +// MARK: - MosaicImageViewDelegate +extension StatusTableViewCell: MosaicImageViewContainerDelegate { + + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) + } + + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { + delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapContentWarningVisualEffectView: visualEffectView) + } + +} + // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCell: ActionToolbarContainerDelegate { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) {