diff --git a/Localization/app.json b/Localization/app.json index bc810f759..a23e5f53e 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -198,7 +198,9 @@ "attachment": { "photo": "photo", "video": "video", - "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon." + "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.", + "description_photo": "Describe photo for low vision people...", + "description_video": "Describe what’s happening for low vision people..." } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 99d1cdf2e..c898d3efb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -214,6 +214,9 @@ DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; + DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -500,6 +503,8 @@ DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.swift; sourceTree = ""; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; + DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -530,6 +535,7 @@ DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, @@ -1029,6 +1035,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, + DB9A488326034BD7008B817C /* APIService+Status.swift */, ); path = APIService; sourceTree = ""; @@ -1113,6 +1120,7 @@ DB789A2125F9F76D0071ACA0 /* TableViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, ); path = Compose; @@ -1407,6 +1415,7 @@ 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, DB6672A225F9FDE500D60309 /* TwitterTextEditor */, + DB9A487D2603456B008B817C /* UITextView+Placeholder */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1537,6 +1546,7 @@ 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1748,6 +1758,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, @@ -1818,6 +1829,7 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, + DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, @@ -2489,6 +2501,14 @@ minimumVersion = 1.0.0; }; }; + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2536,6 +2556,11 @@ package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; + DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { + isa = XCSwiftPackageProductDependency; + package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; + productName = "UITextView+Placeholder"; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 21afdd4cd..212a2d7a6 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -108,6 +108,15 @@ "revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91", "version": "1.0.0" } + }, + { + "package": "UITextView+Placeholder", + "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", + "state": { + "branch": null, + "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", + "version": "1.4.1" + } } ] }, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index a99e1f49a..772a327b2 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -42,6 +42,7 @@ extension ComposeStatusSection { return cell case .input(let replyToTootObjectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusContentTableViewCell.self), for: indexPath) as! ComposeStatusContentTableViewCell + cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { @@ -55,15 +56,19 @@ extension ComposeStatusSection { cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate // self size input cell cell.composeContent + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { text in tableView.beginUpdates() tableView.endUpdates() + // bind input data + attribute.composeContent.value = text } .store(in: &cell.disposeBag) return cell case .attachment(let attachmentService): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self), for: indexPath) as! ComposeStatusAttachmentTableViewCell + cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value cell.delegate = composeStatusAttachmentTableViewCellDelegate attachmentService.imageData .receive(on: DispatchQueue.main) @@ -93,6 +98,17 @@ extension ComposeStatusSection { cell.attachmentContainerView.emptyStateView.isHidden = error == nil } .store(in: &cell.disposeBag) + NotificationCenter.default.publisher( + for: UITextView.textDidChangeNotification, + object: cell.attachmentContainerView.descriptionTextView + ) + .receive(on: DispatchQueue.main) + .sink { notification in + guard let textField = notification.object as? UITextView else { return } + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + attachmentService.description.value = text + } + .store(in: &cell.disposeBag) return cell } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 59ee4a49b..4bfb7bbf8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -149,6 +149,10 @@ internal enum L10n { internal static func attachmentBroken(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) } + /// Describe photo for low vision people... + internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto") + /// Describe what’s happening for low vision people... + internal static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo") /// photo internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo") /// video diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json index dabccc33e..bc9f94fcc 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.353", - "green" : "0.251", - "red" : "0.875" + "blue" : "66", + "green" : "46", + "red" : "163" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 64a3e17e8..e751f6204 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -40,6 +40,8 @@ "Common.Countable.Photo.Single" = "photo"; "Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be uploaded to Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; "Scene.Compose.Attachment.Photo" = "photo"; "Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.ComposeAction" = "Publish"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 7fd5dfd14..2870239ec 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -22,7 +22,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { private var suffixedAttachmentViews: [UIView] = [] - let composeTootBarButtonItem: UIBarButtonItem = { + let publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) @@ -32,7 +32,10 @@ final class ComposeViewController: UIViewController, NeedsDependency { button.setTitleColor(.white, for: .normal) button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16) button.adjustsImageWhenHighlighted = false - let barButtonItem = UIBarButtonItem(customView: button) + return button + }() + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: publishButton) return barButtonItem }() @@ -85,7 +88,8 @@ extension ComposeViewController { .store(in: &disposeBag) view.backgroundColor = Asset.Colors.Background.systemBackground.color navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) - navigationItem.rightBarButtonItem = composeTootBarButtonItem + navigationItem.rightBarButtonItem = publishBarButtonItem + publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -171,7 +175,7 @@ extension ComposeViewController { viewModel.isComposeTootBarButtonItemEnabled .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: composeTootBarButtonItem) + .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) // bind custom emojis @@ -187,6 +191,16 @@ extension ComposeViewController { self.textEditorView()?.setNeedsUpdateTextAttributes() }) .store(in: &disposeBag) + + // bind image picker toolbar state + viewModel.attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + self.resetImagePicker() + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -241,6 +255,22 @@ extension ComposeViewController { alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) } + + private func resetImagePicker() { + var configuration = PHPickerConfiguration() + configuration.filter = .images + let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count) + configuration.selectionLimit = selectionLimit + + imagePicker = createImagePicker(configuration: configuration) + } + + private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + } + } extension ComposeViewController { @@ -254,6 +284,16 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } + @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { + // TODO: handle error + return + } + + dismiss(animated: true, completion: nil) + } + } // MARK: - TextEditorViewTextAttributesDelegate diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift new file mode 100644 index 000000000..0033bff37 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -0,0 +1,88 @@ +// +// ComposeViewModel+PublishState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +extension ComposeViewModel { + class PublishState: GKState { + weak var viewModel: ComposeViewModel? + + init(viewModel: ComposeViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension ComposeViewModel.PublishState { + class Initial: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Publishing.self + } + } + + class Publishing: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let mastodonAuthenticationBox = viewModel.activeAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let query = Mastodon.API.Statuses.PublishStatusQuery( + status: viewModel.composeStatusAttribute.composeContent.value, + mediaIDs: nil + ) + viewModel.context.apiService.publishStatus( + domain: mastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Finish.self) + } + } receiveValue: { status in + + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Publishing.self || stateClass == Finish.self + } + } + + class Finish: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 83082d837..67d3adb42 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import GameplayKit final class ComposeViewModel { @@ -18,11 +19,22 @@ final class ComposeViewModel { let context: AppContext let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() - let composeContent = CurrentValueSubject("") let activeAuthentication: CurrentValueSubject + let activeAuthenticationBox: CurrentValueSubject // output var diffableDataSource: UITableViewDiffableDataSource! + private(set) lazy var publishStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + PublishState.Initial(viewModel: self), + PublishState.Publishing(viewModel: self), + PublishState.Fail(viewModel: self), + PublishState.Finish(viewModel: self), + ]) + stateMachine.enter(PublishState.Initial.self) + return stateMachine + }() // UI & UX let title: CurrentValueSubject @@ -47,12 +59,16 @@ final class ComposeViewModel { case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) + self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init // bind active authentication context.authenticationService.activeMastodonAuthentication .assign(to: \.value, on: activeAuthentication) .store(in: &disposeBag) + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeAuthenticationBox) + .store(in: &disposeBag) // bind avatar and names activeAuthentication diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index d61f2e672..5bf7020a3 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -6,11 +6,14 @@ // import UIKit +import UITextView_Placeholder final class AttachmentContainerView: UIView { static let containerViewCornerRadius: CGFloat = 4 + var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? + let activityIndicatorView = UIActivityIndicatorView(style: .medium) let previewImageView: UIImageView = { @@ -21,6 +24,34 @@ final class AttachmentContainerView: UIView { }() let emptyStateView = AttachmentContainerView.EmptyStateView() + let descriptionBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + view.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 5, right: 8) + return view + }() + let descriptionBackgroundGradientLayer: CAGradientLayer = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.69).cgColor] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + return gradientLayer + }() + let descriptionTextView: UITextView = { + let textView = UITextView() + textView.showsVerticalScrollIndicator = false + textView.backgroundColor = .clear + textView.textColor = .white + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto + textView.placeholderColor = Asset.Colors.Label.secondary.color + return textView + }() override init(frame: CGRect) { super.init(frame: frame) @@ -46,6 +77,29 @@ extension AttachmentContainerView { previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(descriptionBackgroundView) + NSLayoutConstraint.activate([ + descriptionBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + descriptionBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + descriptionBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + descriptionBackgroundView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.3), + ]) + descriptionBackgroundView.layer.addSublayer(descriptionBackgroundGradientLayer) + descriptionBackgroundViewFrameObservation = descriptionBackgroundView.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in + guard let self = self else { return } + self.descriptionBackgroundGradientLayer.frame = self.descriptionBackgroundView.bounds + } + + descriptionTextView.translatesAutoresizingMaskIntoConstraints = false + descriptionBackgroundView.addSubview(descriptionTextView) + NSLayoutConstraint.activate([ + descriptionTextView.leadingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor), + descriptionTextView.trailingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor), + descriptionBackgroundView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: descriptionTextView.bottomAnchor), + descriptionTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 36), + ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false addSubview(emptyStateView) NSLayoutConstraint.activate([ @@ -62,6 +116,8 @@ extension AttachmentContainerView { activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor), ]) + descriptionBackgroundView.overrideUserInterfaceStyle = .dark + emptyStateView.isHidden = true activityIndicatorView.hidesWhenStopped = true activityIndicatorView.startAnimating() diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift new file mode 100644 index 000000000..dee775476 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -0,0 +1,33 @@ +// +// APIService+Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func publishStatus( + domain: String, + query: Mastodon.API.Statuses.PublishStatusQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Statuses.publishStatus( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Service/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService.swift index e845d2d7e..e29a04408 100644 --- a/Mastodon/Service/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService.swift @@ -17,6 +17,7 @@ final class MastodonAttachmentService { // input let pickerResult: PHPickerResult + let description = CurrentValueSubject(nil) // output let imageData = CurrentValueSubject(nil) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index f01e6cb47..7283411f5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -6,3 +6,62 @@ // import Foundation +import Combine + +extension Mastodon.API.Statuses { + + static func publishNewStatusEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses") + } + + /// Publish new status + /// + /// Post a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `PublishStatusQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func publishStatus( + session: URLSession, + domain: String, + query: PublishStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: publishNewStatusEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct PublishStatusQuery: Codable, PostQuery { + public let status: String? + public let mediaIDs: [String]? + + enum CodingKeys: String, CodingKey { + case status + case mediaIDs = "media_ids" + } + + public init(status: String?, mediaIDs: [String]?) { + self.status = status + self.mediaIDs = mediaIDs + } + } + +} diff --git a/README.md b/README.md index 53e3bf498..61f142bb8 100644 --- a/README.md +++ b/README.md @@ -54,5 +54,6 @@ arch -x86_64 pod install - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) +- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder) ## License