From f455faa273bce6ce41a3cb01fa4f48c245cd4539 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 24 Feb 2021 15:29:16 +0800 Subject: [PATCH] feat: add content warning (CW) for status text. --- .../StringsConvertor/input/en_US/app.json | 4 +- .../output/en.lproj/Localizable.strings | 4 +- Localization/app.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/TimelineSection.swift | 8 +- .../Extension/NSKeyValueObservation.swift | 14 +++ Mastodon/Extension/UIIamge.swift | 13 +++ Mastodon/Generated/Strings.swift | 15 ++- .../Resources/en.lproj/Localizable.strings | 9 +- .../HomeTimelineViewController.swift | 8 ++ .../Scene/Share/View/Content/StatusView.swift | 91 ++++++++++++++++++- .../TableviewCell/StatusTableViewCell.swift | 1 + .../Scene/Welcome/WelcomeViewController.swift | 4 +- 13 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 Mastodon/Extension/NSKeyValueObservation.swift diff --git a/Localization/StringsConvertor/input/en_US/app.json b/Localization/StringsConvertor/input/en_US/app.json index 680d2cb32..8fa054563 100644 --- a/Localization/StringsConvertor/input/en_US/app.json +++ b/Localization/StringsConvertor/input/en_US/app.json @@ -20,7 +20,9 @@ "open_in_safari": "Open in Safari" }, "status": { - "userBoosted": "%s boosted" + "user_boosted": "%s boosted", + "content_warning": "content warning", + "show_post": "Show Post" }, "timeline": { "load_more": "Load More" diff --git a/Localization/StringsConvertor/output/en.lproj/Localizable.strings b/Localization/StringsConvertor/output/en.lproj/Localizable.strings index 92264accf..75dc3999b 100644 --- a/Localization/StringsConvertor/output/en.lproj/Localizable.strings +++ b/Localization/StringsConvertor/output/en.lproj/Localizable.strings @@ -13,7 +13,9 @@ "Common.Controls.Actions.SignIn" = "Sign in"; "Common.Controls.Actions.SignUp" = "Sign up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; -"Common.Controls.Status.Userboosted" = "%@ boosted"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.UserBoosted" = "%@ boosted"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; diff --git a/Localization/app.json b/Localization/app.json index 680d2cb32..8fa054563 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -20,7 +20,9 @@ "open_in_safari": "Open in Safari" }, "status": { - "userBoosted": "%s boosted" + "user_boosted": "%s boosted", + "content_warning": "content warning", + "show_post": "Show Post" }, "timeline": { "load_more": "Load More" diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6b60486f2..2dbcfd676 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -302,6 +303,7 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -895,6 +897,7 @@ 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, + DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, @@ -1338,6 +1341,7 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 0FAA102725E1126A0017CCDE /* PickServerViewController.swift in Sources */, + DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/TimelineSection.swift b/Mastodon/Diffiable/Section/TimelineSection.swift index 18fb05086..ab5a82f27 100644 --- a/Mastodon/Diffiable/Section/TimelineSection.swift +++ b/Mastodon/Diffiable/Section/TimelineSection.swift @@ -79,7 +79,7 @@ extension TimelineSection { cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userboosted(name) + return L10n.Common.Controls.Status.userBoosted(name) }() // set name username avatar @@ -93,6 +93,12 @@ extension TimelineSection { // set text cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) + // set content warning + cell.statusView.updateContentWarningDisplay(isHidden: !(toot.reblog ?? toot).sensitive) + cell.statusView.contentWarningTitle.text = (toot.reblog ?? toot).spoilerText.flatMap { spoilerText in + return L10n.Common.Controls.Status.contentWarning + ": \(spoilerText)" + } ?? L10n.Common.Controls.Status.contentWarning + // prepare media attachments let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } diff --git a/Mastodon/Extension/NSKeyValueObservation.swift b/Mastodon/Extension/NSKeyValueObservation.swift new file mode 100644 index 000000000..fa45364b3 --- /dev/null +++ b/Mastodon/Extension/NSKeyValueObservation.swift @@ -0,0 +1,14 @@ +// +// NSKeyValueObservation.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-24. +// + +import Foundation + +extension NSKeyValueObservation { + func store(in set: inout Set) { + set.insert(self) + } +} diff --git a/Mastodon/Extension/UIIamge.swift b/Mastodon/Extension/UIIamge.swift index 20069f7c7..4f4b350c3 100644 --- a/Mastodon/Extension/UIIamge.swift +++ b/Mastodon/Extension/UIIamge.swift @@ -40,3 +40,16 @@ extension UIImage { return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) } } + +extension UIImage { + func blur(radius: CGFloat) -> UIImage? { + guard let inputImage = CIImage(image: self) else { return nil } + let blurFilter = CIFilter.gaussianBlur() + blurFilter.inputImage = inputImage + blurFilter.radius = Float(radius) + guard let outputImage = blurFilter.outputImage else { return nil } + guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil } + let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) + return image + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index e74eb1b6c..7b9d9eca5 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -11,13 +11,6 @@ import Foundation // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { - internal enum Button { - /// Sign In - internal static let signIn = L10n.tr("Localizable", "Button.SignIn") - /// Sign Up - internal static let signUp = L10n.tr("Localizable", "Button.SignUp") - } - internal enum Common { internal enum Controls { internal enum Actions { @@ -53,9 +46,13 @@ 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") + /// Show Post + internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") /// %@ boosted - internal static func userboosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.Userboosted", String(describing: p1)) + internal static func userBoosted(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } } internal enum Timeline { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c4ef68d57..75dc3999b 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -13,11 +13,10 @@ "Common.Controls.Actions.SignIn" = "Sign in"; "Common.Controls.Actions.SignUp" = "Sign up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; -"Common.Controls.Status.Userboosted" = "%@ boosted"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.UserBoosted" = "%@ boosted"; "Common.Controls.Timeline.LoadMore" = "Load More"; - -"Button.SignUp" = "Sign Up"; -"Button.SignIn" = "Sign In"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; "Scene.HomeTimeline.Title" = "Home"; @@ -38,4 +37,4 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index fc9b7447b..2e5ca5e77 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -206,6 +206,14 @@ extension HomeTimelineViewController: UITableViewDelegate { return ceil(frame.height) } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if let cell = cell as? StatusTableViewCell { + DispatchQueue.main.async { + cell.statusView.drawContentWarningImageView() + } + } + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 0f3998790..f36150f0f 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -14,6 +14,7 @@ final class StatusView: UIView { static let avatarImageSize = CGSize(width: 42, height: 42) static let avatarImageCornerRadius: CGFloat = 4 + static let contentWarningBlurRadius: CGFloat = 12 let headerContainerStackView = UIStackView() @@ -72,8 +73,33 @@ final class StatusView: UIView { }() let statusContainerStackView = UIStackView() + let statusTextContainerView = UIView() + let statusContentWarningContainerStackView = UIStackView() + var statusContentWarningContainerStackViewBottomLayoutConstraint: NSLayoutConstraint! + let contentWarningTitle: UILabel = { + 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 + return label + }() + let contentWarningActionButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .medium)) + button.setTitleColor(Asset.Colors.Label.highlight.color, for: .normal) + button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) + return button + }() let mosaicImageView = MosaicImageView() + + // do not use visual effect view due to we blur text only without background + let contentWarningBlurContentImageView: UIImageView = { + let imageView = UIImageView() + imageView.backgroundColor = .secondarySystemGroupedBackground + imageView.layer.masksToBounds = false + return imageView + }() let actionToolbarContainer: ActionToolbarContainer = { let actionToolbarContainer = ActionToolbarContainer() @@ -183,16 +209,77 @@ extension StatusView { containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 - statusContainerStackView.addArrangedSubview(activeTextLabel) - activeTextLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical) + statusContainerStackView.addArrangedSubview(statusTextContainerView) + statusTextContainerView.setContentCompressionResistancePriority(.required - 2, for: .vertical) + activeTextLabel.translatesAutoresizingMaskIntoConstraints = false + statusTextContainerView.addSubview(activeTextLabel) + NSLayoutConstraint.activate([ + activeTextLabel.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), + activeTextLabel.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), + activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), + statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor), + ]) + contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false + statusTextContainerView.addSubview(contentWarningBlurContentImageView) + NSLayoutConstraint.activate([ + activeTextLabel.topAnchor.constraint(equalTo: contentWarningBlurContentImageView.topAnchor, constant: StatusView.contentWarningBlurRadius), + activeTextLabel.leadingAnchor.constraint(equalTo: contentWarningBlurContentImageView.leadingAnchor, constant: StatusView.contentWarningBlurRadius), + + ]) + statusContentWarningContainerStackView.translatesAutoresizingMaskIntoConstraints = false + statusContentWarningContainerStackView.axis = .vertical + statusContentWarningContainerStackView.distribution = .fill + statusContentWarningContainerStackView.alignment = .center + statusTextContainerView.addSubview(statusContentWarningContainerStackView) + statusContentWarningContainerStackViewBottomLayoutConstraint = statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: statusContentWarningContainerStackView.bottomAnchor, constant: 8) + NSLayoutConstraint.activate([ + statusContentWarningContainerStackView.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), + statusContentWarningContainerStackView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), + statusContentWarningContainerStackView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), + statusContentWarningContainerStackViewBottomLayoutConstraint, + ]) + statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) + statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) statusContainerStackView.addArrangedSubview(mosaicImageView) + // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) headerContainerStackView.isHidden = true mosaicImageView.isHidden = true + contentWarningBlurContentImageView.isHidden = true + statusContentWarningContainerStackView.isHidden = true + statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + } + +} + +extension StatusView { + + func cleanUpContentWarning() { + contentWarningBlurContentImageView.image = nil + } + + func drawContentWarningImageView() { + guard activeTextLabel.frame != .zero, let text = activeTextLabel.text, !text.isEmpty else { + cleanUpContentWarning() + return + } + + let image = UIGraphicsImageRenderer(size: activeTextLabel.frame.size).image { context in + activeTextLabel.draw(activeTextLabel.bounds) + } + .blur(radius: StatusView.contentWarningBlurRadius) + contentWarningBlurContentImageView.contentScaleFactor = traitCollection.displayScale + contentWarningBlurContentImageView.image = image + } + + func updateContentWarningDisplay(isHidden: Bool) { + contentWarningBlurContentImageView.isHidden = isHidden + statusContentWarningContainerStackView.isHidden = isHidden + statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = !isHidden } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 7d429c765..187079be3 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() + statusView.cleanUpContentWarning() disposeBag.removeAll() observations.removeAll() } diff --git a/Mastodon/Scene/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Welcome/WelcomeViewController.swift index 745d76b6c..99aa89f92 100644 --- a/Mastodon/Scene/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Welcome/WelcomeViewController.swift @@ -37,7 +37,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { let signUpButton: PrimaryActionButton = { let button = PrimaryActionButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) - button.setTitle(L10n.Button.signUp, for: .normal) + button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -45,7 +45,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { let signInButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) - button.setTitle(L10n.Button.signIn, for: .normal) + button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) button.setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0) button.translatesAutoresizingMaskIntoConstraints = false