diff --git a/Localization/app.json b/Localization/app.json index 3a3db1300..10c672a73 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -216,6 +216,9 @@ "one_day": "1 Day", "three_days": "3 Days", "seven_days": "7 Days" + }, + "content_warning": { + "placeholder": "Write an accurate warning here..." } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d934ae236..3e3fc4680 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -1122,6 +1124,7 @@ DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */, DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */, + DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */, ); path = View; sourceTree = ""; @@ -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 */, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 86e8c6228..88bff36c3 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -32,11 +32,16 @@ extension ComposeStatusItem { let username = CurrentValueSubject(nil) let composeContent = CurrentValueSubject(nil) + let isContentWarningComposing = CurrentValueSubject(false) + let contentWarningContent = CurrentValueSubject("") + 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) { diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 49f65a7c4..4a0e90d76 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -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): diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 86c3bf13e..8e7da586d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -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") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index dd34cbfe1..860bf2db5 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 1537215d0..f1fe6b541 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -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() + let contentWarningContent = PassthroughSubject() 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) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index abb486112..7e1dc07b3 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -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) { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 85ad46684..222ac938a 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -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, Error>] = { var subscriptions: [AnyPublisher, 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, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 095d37899..c2e357d01 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -21,6 +21,7 @@ final class ComposeViewModel { let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let isPollComposing = CurrentValueSubject(false) let isCustomEmojiComposing = CurrentValueSubject(false) + let isContentWarningComposing = CurrentValueSubject(false) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject @@ -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) diff --git a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift new file mode 100644 index 000000000..510edd464 --- /dev/null +++ b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift @@ -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 + diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 4c03d1baa..6d7800b04 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index e4bff7d54..d1fb95f40 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -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