feat: add content warning editor for status compose scene

This commit is contained in:
CMK 2021-03-25 18:17:05 +08:00
parent df66cc6b4a
commit 610ee36835
13 changed files with 238 additions and 14 deletions

View File

@ -216,6 +216,9 @@
"one_day": "1 Day",
"three_days": "3 Days",
"seven_days": "7 Days"
},
"content_warning": {
"placeholder": "Write an accurate warning here..."
}
}
}

View File

@ -245,6 +245,7 @@
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; };
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
@ -553,6 +554,7 @@
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
@ -1122,6 +1124,7 @@
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */,
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */,
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */,
);
path = View;
sourceTree = "<group>";
@ -1904,6 +1907,7 @@
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,

View File

@ -32,11 +32,16 @@ extension ComposeStatusItem {
let username = CurrentValueSubject<String?, Never>(nil)
let composeContent = CurrentValueSubject<String?, Never>(nil)
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
let contentWarningContent = CurrentValueSubject<String, Never>("")
static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool {
return lhs.avatarURL.value == rhs.avatarURL.value &&
lhs.displayName.value == rhs.displayName.value &&
lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value
lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value &&
lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value &&
lhs.contentWarningContent.value == rhs.contentWarningContent.value
}
func hash(into hasher: inout Hasher) {

View File

@ -68,6 +68,37 @@ extension ComposeStatusSection {
attribute.composeContent.value = text
}
.store(in: &cell.disposeBag)
attribute.isContentWarningComposing
.receive(on: DispatchQueue.main)
.sink { isContentWarningComposing in
// self size input cell
collectionView.collectionViewLayout.invalidateLayout()
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
cell.statusContentWarningEditorView.alpha = 0
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
cell.statusContentWarningEditorView.alpha = 1
} completion: { _ in
if isContentWarningComposing {
cell.statusContentWarningEditorView.textView.becomeFirstResponder()
}
// do nothing
}
// restore responder if needs
if cell.statusContentWarningEditorView.textView.isFirstResponder {
cell.textEditorView.isEditing = true
}
}
.store(in: &cell.disposeBag)
cell.contentWarningContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { text in
// self size input cell
collectionView.collectionViewLayout.invalidateLayout()
// bind input data
attribute.contentWarningContent.value = text
}
.store(in: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
return cell
case .attachment(let attachmentService):

View File

@ -162,6 +162,10 @@ internal enum L10n {
/// video
internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video")
}
internal enum ContentWarning {
/// Write an accurate warning here...
internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder")
}
internal enum MediaSelection {
/// Browse
internal static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse")

View File

@ -47,6 +47,7 @@ uploaded to Mastodon.";
"Scene.Compose.Attachment.Video" = "video";
"Scene.Compose.ComposeAction" = "Publish";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
"Scene.Compose.MediaSelection.Browse" = "Browse";
"Scene.Compose.MediaSelection.Camera" = "Take Photo";
"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library";

View File

@ -5,6 +5,7 @@
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import TwitterTextEditor
@ -15,6 +16,8 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
let statusView = StatusView()
let statusContentWarningEditorView = StatusContentWarningEditorView()
let textEditorView: TextEditorView = {
let textEditorView = TextEditorView()
textEditorView.font = .preferredFont(forTextStyle: .body)
@ -25,7 +28,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
return textEditorView
}()
// output
let composeContent = PassthroughSubject<String, Never>()
let contentWarningContent = PassthroughSubject<String, Never>()
override init(frame: CGRect) {
super.init(frame: frame)
@ -45,10 +50,20 @@ extension ComposeStatusContentCollectionViewCell {
// selectionStyle = .none
preservesSuperviewLayoutMargins = true
statusContentWarningEditorView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(statusContentWarningEditorView)
NSLayoutConstraint.activate([
statusContentWarningEditorView.topAnchor.constraint(equalTo: contentView.topAnchor),
statusContentWarningEditorView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
statusContentWarningEditorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
statusContentWarningEditorView.preservesSuperviewLayoutMargins = true
statusContentWarningEditorView.containerBackgroundView.isHidden = false
statusView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(statusView)
NSLayoutConstraint.activate([
statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
statusView.topAnchor.constraint(equalTo: statusContentWarningEditorView.bottomAnchor, constant: 20),
statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
])
@ -70,23 +85,39 @@ extension ComposeStatusContentCollectionViewCell {
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
// TODO:
statusContentWarningEditorView.textView.delegate = self
textEditorView.changeObserver = self
}
override func didMoveToWindow() {
super.didMoveToWindow()
statusContentWarningEditorView.containerView.isHidden = true
}
}
// MARK: - UITextViewDelegate
// MARK: - TextEditorViewChangeObserver
extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
guard changeResult.isTextChanged else { return }
composeContent.send(textEditorView.text)
}
}
// MARK: - UITextViewDelegate
extension ComposeStatusContentCollectionViewCell: UITextViewDelegate {
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// disable input line break
guard text != "\n" else { return false }
return true
}
func textViewDidChange(_ textView: UITextView) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text)
guard textView === statusContentWarningEditorView.textView else { return }
// replace line break with space
textView.text = textView.text.replacingOccurrences(of: "\n", with: " ")
contentWarningContent.send(textView.text)
}
}

View File

@ -623,6 +623,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) {
viewModel.isContentWarningComposing.value.toggle()
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) {

View File

@ -61,6 +61,14 @@ extension ComposeViewModel.PublishState {
guard viewModel.isPollComposing.value else { return nil }
return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds
}()
let sensitive: Bool = viewModel.isContentWarningComposing.value
let spoilerText: String? = {
let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
return nil
}
return text
}()
let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
@ -92,7 +100,9 @@ extension ComposeViewModel.PublishState {
status: viewModel.composeStatusAttribute.composeContent.value,
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn
pollExpiresIn: pollExpiresIn,
sensitive: sensitive,
spoilerText: spoilerText
)
return viewModel.context.apiService.publishStatus(
domain: domain,

View File

@ -21,6 +21,7 @@ final class ComposeViewModel {
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
let isPollComposing = CurrentValueSubject<Bool, Never>(false)
let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false)
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never>
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
@ -76,6 +77,10 @@ final class ComposeViewModel {
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
.store(in: &disposeBag)
isContentWarningComposing
.assign(to: \.value, on: composeStatusAttribute.isContentWarningComposing)
.store(in: &disposeBag)
// bind active authentication
context.authenticationService.activeMastodonAuthentication
.assign(to: \.value, on: activeAuthentication)

View File

@ -0,0 +1,114 @@
//
// StatusContentWarningEditorView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-25.
//
import UIKit
final class StatusContentWarningEditorView: UIView {
let containerView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
return view
}()
// due to section following readable inset. We overlap the bleeding to make backgorund fill
// default hidden
let containerBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
view.isHidden = true
return view
}()
let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(systemName: "exclamationmark.shield")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 30, weight: .regular)).withRenderingMode(.alwaysTemplate)
imageView.tintColor = Asset.Colors.Label.primary.color
imageView.contentMode = .center
return imageView
}()
let textView: UITextView = {
let textView = UITextView()
textView.font = .preferredFont(forTextStyle: .body)
textView.isScrollEnabled = false
textView.placeholder = L10n.Scene.Compose.ContentWarning.placeholder
textView.backgroundColor = .clear
return textView
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension StatusContentWarningEditorView {
private func _init() {
let contentWarningStackView = UIStackView()
contentWarningStackView.axis = .horizontal
contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentWarningStackView)
NSLayoutConstraint.activate([
contentWarningStackView.topAnchor.constraint(equalTo: topAnchor),
contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
contentWarningStackView.addArrangedSubview(containerView)
containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(containerBackgroundView)
NSLayoutConstraint.activate([
containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor),
containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024),
containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024),
containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
iconImageView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(iconImageView)
NSLayoutConstraint.activate([
iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar
])
iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
textView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(textView)
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6),
textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset
textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor),
containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6),
])
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct StatusContentWarningEditorView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
StatusContentWarningEditorView()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -23,6 +23,7 @@ final class StatusView: UIView {
static let avatarImageSize = CGSize(width: 42, height: 42)
static let avatarImageCornerRadius: CGFloat = 4
static let avatarToLabelSpacing: CGFloat = 5
static let contentWarningBlurRadius: CGFloat = 12
static let boostIconImage: UIImage = {
@ -249,7 +250,7 @@ extension StatusView {
let authorContainerStackView = UIStackView()
containerStackView.addArrangedSubview(authorContainerStackView)
authorContainerStackView.axis = .horizontal
authorContainerStackView.spacing = 5
authorContainerStackView.spacing = StatusView.avatarToLabelSpacing
// avatar
avatarView.translatesAutoresizingMaskIntoConstraints = false

View File

@ -98,12 +98,24 @@ extension Mastodon.API.Statuses {
public let mediaIDs: [String]?
public let pollOptions: [String]?
public let pollExpiresIn: Int?
public let sensitive: Bool?
public let spoilerText: String?
public init(status: String?, mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?) {
public init(
status: String?,
mediaIDs: [String]?,
pollOptions: [String]?,
pollExpiresIn: Int?,
sensitive: Bool?,
spoilerText: String?
) {
self.status = status
self.mediaIDs = mediaIDs
self.pollOptions = pollOptions
self.pollExpiresIn = pollExpiresIn
self.sensitive = sensitive
self.spoilerText = spoilerText
}
var contentType: String? {
@ -121,6 +133,8 @@ extension Mastodon.API.Statuses {
data.append(Data.multipart(key: "poll[options][]", value: pollOption))
}
pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) }
sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) }
spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) }
data.append(Data.multipartEnd())
return data