feat: add content warning (CW) for status text.

This commit is contained in:
CMK 2021-02-24 15:29:16 +08:00
parent 98ebddc438
commit f455faa273
13 changed files with 157 additions and 22 deletions

View File

@ -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"

View File

@ -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";

View File

@ -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"

View File

@ -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 = "<group>"; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = "<group>"; };
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
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 */,

View File

@ -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 }

View File

@ -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<NSKeyValueObservation>) {
set.insert(self)
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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.";

View File

@ -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

View File

@ -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
}
}

View File

@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
statusView.cleanUpContentWarning()
disposeBag.removeAll()
observations.removeAll()
}

View File

@ -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