feat: make UI works

This commit is contained in:
CMK 2021-07-19 17:12:45 +08:00
parent 079e611f33
commit 1cdbd7fa2a
56 changed files with 1548 additions and 609 deletions

File diff suppressed because it is too large Load Diff

View File

@ -12,27 +12,27 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>20</integer>
<integer>23</integer>
</dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>5</integer>
<integer>2</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>7</integer>
<integer>3</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>1</integer>
</dict>
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>

View File

@ -163,6 +163,15 @@
"version": "1.0.0"
}
},
{
"package": "Introspect",
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
"state": {
"branch": null,
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
"version": "0.1.3"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",

View File

@ -9,6 +9,7 @@ import UIKit
import Foundation
import ActiveLabel
import os.log
import MastodonUI
extension ActiveLabel {
@ -58,7 +59,7 @@ extension ActiveLabel {
}
extension ActiveLabel {
func configure(text: String) {
public func configure(text: String) {
attributedText = nil
activeEntities.removeAll()
self.text = text
@ -69,7 +70,7 @@ extension ActiveLabel {
extension ActiveLabel {
/// status content
func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
public func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
attributedText = nil
activeEntities.removeAll()
@ -83,7 +84,7 @@ extension ActiveLabel {
}
}
func configure(contentParseResult parseResult: MastodonStatusContent.ParseResult?) {
public func configure(contentParseResult parseResult: MastodonStatusContent.ParseResult?) {
attributedText = nil
activeEntities.removeAll()
text = parseResult?.trimmed ?? ""
@ -92,14 +93,14 @@ extension ActiveLabel {
}
/// account note
func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) {
public func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) {
configure(content: note, emojiDict: emojiDict)
}
}
extension ActiveLabel {
/// account field
func configure(field: String, emojiDict: MastodonStatusContent.EmojiDict) {
public func configure(field: String, emojiDict: MastodonStatusContent.EmojiDict) {
configure(content: field, emojiDict: emojiDict)
}
}

View File

@ -5,59 +5,59 @@
// Created by MainasuK Cirno on 2021-3-30.
//
import Foundation
import ActiveLabel
enum MastodonField {
@available(*, deprecated, message: "rely on server meta rendering")
static func parse(field string: String, emojiDict: MastodonStatusContent.EmojiDict) -> ParseResult {
// use content parser get emoji entities
let value = string
var string = string
var entities: [ActiveEntity] = []
do {
let contentParseresult = try MastodonStatusContent.parse(content: string, emojiDict: emojiDict)
string = contentParseresult.trimmed
entities.append(contentsOf: contentParseresult.activeEntities)
} catch {
// assertionFailure(error.localizedDescription)
}
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
for match in mentionMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
let entity = ActiveEntity(range: match.range, type: .mention(text, userInfo: nil))
entities.append(entity)
}
for match in hashtagMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
let entity = ActiveEntity(range: match.range, type: .hashtag(text, userInfo: nil))
entities.append(entity)
}
for match in urlMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
let entity = ActiveEntity(range: match.range, type: .url(text, trimmed: text, url: text, userInfo: nil))
entities.append(entity)
}
return ParseResult(value: value, trimmed: string, activeEntities: entities)
}
}
extension MastodonField {
struct ParseResult {
let value: String
let trimmed: String
let activeEntities: [ActiveEntity]
}
}
//import Foundation
//import ActiveLabel
//
//enum MastodonField {
//
// @available(*, deprecated, message: "rely on server meta rendering")
// public static func parse(field string: String, emojiDict: MastodonStatusContent.EmojiDict) -> ParseResult {
// // use content parser get emoji entities
// let value = string
//
// var string = string
// var entities: [ActiveEntity] = []
//
// do {
// let contentParseresult = try MastodonStatusContent.parse(content: string, emojiDict: emojiDict)
// string = contentParseresult.trimmed
// entities.append(contentsOf: contentParseresult.activeEntities)
// } catch {
// // assertionFailure(error.localizedDescription)
// }
//
// let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
// let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
// let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
//
//
// for match in mentionMatches {
// guard let text = string.substring(with: match, at: 0) else { continue }
// let entity = ActiveEntity(range: match.range, type: .mention(text, userInfo: nil))
// entities.append(entity)
// }
//
// for match in hashtagMatches {
// guard let text = string.substring(with: match, at: 0) else { continue }
// let entity = ActiveEntity(range: match.range, type: .hashtag(text, userInfo: nil))
// entities.append(entity)
// }
//
// for match in urlMatches {
// guard let text = string.substring(with: match, at: 0) else { continue }
// let entity = ActiveEntity(range: match.range, type: .url(text, trimmed: text, url: text, userInfo: nil))
// entities.append(entity)
// }
//
// return ParseResult(value: value, trimmed: string, activeEntities: entities)
// }
//
//}
//
//extension MastodonField {
// public struct ParseResult {
// let value: String
// let trimmed: String
// let activeEntities: [ActiveEntity]
// }
//}

View File

@ -7,9 +7,9 @@
import Foundation
final class MastodonMetricFormatter: Formatter {
final public class MastodonMetricFormatter: Formatter {
func string(from number: Int) -> String? {
public func string(from number: Int) -> String? {
let isPositive = number >= 0
let symbol = isPositive ? "" : "-"

View File

@ -7,19 +7,19 @@
import Foundation
enum MastodonRegex {
public enum MastodonRegex {
/// mention, hashtag.
/// @...
/// #...
static let highlightPattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))"
public static let highlightPattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))"
/// emoji
/// :shortcode:
/// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
/// precondition :\B with following space
static let emojiPattern = "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))"
public static let emojiPattern = "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))"
/// mention, hashtag, emoji
/// @
/// #
/// :
static let autoCompletePattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)"
public static let autoCompletePattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)"
}

View File

@ -8,7 +8,7 @@
import UIKit
extension MastodonStatusContent {
struct Appearance {
public struct Appearance {
let attributes: [NSAttributedString.Key: Any]
let urlAttributes: [NSAttributedString.Key: Any]
let hashtagAttributes: [NSAttributedString.Key: Any]

View File

@ -9,20 +9,20 @@ import Foundation
import ActiveLabel
extension MastodonStatusContent {
struct ParseResult: Hashable {
let document: String
let original: String
let trimmed: String
let activeEntities: [ActiveEntity]
public struct ParseResult: Hashable {
public let document: String
public let original: String
public let trimmed: String
public let activeEntities: [ActiveEntity]
static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool {
public static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool {
return lhs.document == rhs.document
&& lhs.original == rhs.original
&& lhs.trimmed == rhs.trimmed
&& lhs.activeEntities.count == rhs.activeEntities.count // FIXME:
}
func hash(into hasher: inout Hasher) {
public func hash(into hasher: inout Hasher) {
hasher.combine(document)
hasher.combine(original)
hasher.combine(trimmed)
@ -57,7 +57,7 @@ extension ActiveEntityType {
static let appScheme = "mastodon"
init?(url: URL) {
public init?(url: URL) {
guard let scheme = url.scheme?.lowercased() else { return nil }
guard scheme == ActiveEntityType.appScheme else {
self = .url("", trimmed: "", url: url.absoluteString, userInfo: nil)
@ -78,7 +78,7 @@ extension ActiveEntityType {
return nil
}
var uri: URL? {
public var uri: URL? {
switch self {
case .url(_, _, let url, _):
return URL(string: url)

View File

@ -10,14 +10,14 @@ import Combine
import ActiveLabel
import Fuzi
enum MastodonStatusContent {
public enum MastodonStatusContent {
typealias EmojiShortcode = String
typealias EmojiDict = [EmojiShortcode: URL]
public typealias EmojiShortcode = String
public typealias EmojiDict = [EmojiShortcode: URL]
static let workingQueue = DispatchQueue(label: "org.joinmastodon.app.ActiveLabel.working-queue", qos: .userInteractive, attributes: .concurrent)
static func parseResult(content: String, emojiDict: MastodonStatusContent.EmojiDict) -> AnyPublisher<MastodonStatusContent.ParseResult?, Never> {
public static func parseResult(content: String, emojiDict: MastodonStatusContent.EmojiDict) -> AnyPublisher<MastodonStatusContent.ParseResult?, Never> {
return Future { promise in
self.workingQueue.async {
let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict)
@ -27,7 +27,7 @@ enum MastodonStatusContent {
.eraseToAnyPublisher()
}
static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult {
public static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult {
let document: String = {
var content = content
for (shortcode, url) in emojiDict {

View File

@ -19,7 +19,8 @@ extension UserDefaults {
@objc dynamic var preferredStaticAvatar: Bool {
get {
register(defaults: [#function: false])
// default false
// without set register to profile timeline performance
return bool(forKey: #function)
}
set { self[#function] = newValue }

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonExtension
extension UserDefaults {

View File

@ -1,12 +0,0 @@
//
// SplashPreference.swift
// Mastodon
//
// Created by Cirno MainasuK on 2020-2-4.
//
import UIKit
extension UserDefaults {
// TODO: splash scene
}

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonExtension
extension UserDefaults {

View File

@ -24,11 +24,22 @@ extension AvatarConfigurableView {
public func configure(with configuration: AvatarConfigurableViewConfiguration) {
let placeholderImage: UIImage = {
guard let placeholderImage = configuration.placeholderImage else {
#if APP_EXTENSION
let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill)
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
return placeholderImage
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
.af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: false)
} else {
return placeholderImage.af.imageRoundedIntoCircle()
}
#else
return AppContext.shared.placeholderImageCacheService.image(
color: .systemFill,
size: Self.configurableAvatarImageSize,
cornerRadius: Self.configurableAvatarImageCornerRadius
)
#endif
}
return placeholderImage
}()
@ -115,7 +126,7 @@ extension AvatarConfigurableView {
}
struct AvatarConfigurableViewConfiguration {
let avatarImageURL: URL?
let placeholderImage: UIImage?
let borderColor: UIColor?

View File

@ -8,13 +8,16 @@
import os.log
import UIKit
import Combine
import MastodonUI
protocol ComposeStatusAttachmentCollectionViewCellDelegate: AnyObject {
func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton)
}
final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
let logger = Logger(subsystem: "ComposeStatusAttachmentCollectionViewCell", category: "UI")
var disposeBag = Set<AnyCancellable>()
static let verticalMarginHeight: CGFloat = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height * 0.5
@ -58,7 +61,7 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}
@ -96,7 +99,7 @@ extension ComposeStatusAttachmentCollectionViewCell {
extension ComposeStatusAttachmentCollectionViewCell {
@objc private func removeButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.composeStatusAttachmentCollectionViewCell(self, removeButtonDidPressed: sender)
}

View File

@ -5,14 +5,16 @@
// Created by MainasuK Cirno on 2021-6-28.
//
import os.log
import UIKit
import Combine
import MetaTextView
import UITextView_Placeholder
final class ComposeStatusContentTableViewCell: UITableViewCell {
let logger = Logger(subsystem: "ComposeStatusContentTableViewCell", category: "UI")
var disposeBag = Set<AnyCancellable>()
let statusView = ReplicaStatusView()
@ -149,7 +151,7 @@ extension ComposeStatusContentTableViewCell: UITextViewDelegate {
}
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)
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): text: \(textView.text ?? "<nil>")")
guard textView === statusContentWarningEditorView.textView else { return }
// replace line break with space
textView.text = textView.text.replacingOccurrences(of: "\n", with: " ")

View File

@ -76,7 +76,11 @@ final class ComposeViewController: UIViewController, NeedsDependency {
let composeToolbarView = ComposeToolbarView()
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
let composeToolbarBackgroundView = UIView()
let composeToolbarBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
return view
}()
static func createPhotoLibraryPickerConfiguration(selectionLimit: Int = 4) -> PHPickerConfiguration {
var configuration = PHPickerConfiguration()
@ -189,7 +193,7 @@ extension ComposeViewController {
])
tableView.delegate = self
viewModel.setupDiffableDataSource(
viewModel.setupDataSource(
tableView: tableView,
metaTextDelegate: self,
metaTextViewDelegate: self,
@ -264,7 +268,6 @@ extension ComposeViewController {
self.view.layoutIfNeeded()
}
}
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
return
}
// isShow AND dock state
@ -280,14 +283,12 @@ extension ComposeViewController {
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
// adjust inset for collectionView
// adjust inset for tableView
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
let padding = contentFrame.maxY + extraMargin - endFrame.minY
guard padding > 0 else {
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.updateKeyboardBackground(isKeyboardDisplay: false)
return
}
@ -297,7 +298,6 @@ extension ComposeViewController {
self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height
self.view.layoutIfNeeded()
}
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
})
.store(in: &disposeBag)
@ -587,10 +587,6 @@ extension ComposeViewController {
imagePicker.delegate = self
return imagePicker
}
private func updateKeyboardBackground(isKeyboardDisplay: Bool) {
composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
}
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemElevatedBackgroundColor

View File

@ -16,7 +16,7 @@ import MetaTextView
extension ComposeViewModel {
func setupDiffableDataSource(
func setupDataSource(
tableView: UITableView,
metaTextDelegate: MetaTextDelegate,
metaTextViewDelegate: UITextViewDelegate,

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonUI
extension AttachmentContainerView {
final class EmptyStateView: UIView {

View File

@ -96,9 +96,6 @@ final class ComposeToolbarView: UIView {
extension ComposeToolbarView {
private func _init() {
// magic keyboard color (iOS 14):
// light with white background: RGB 214 216 222
// dark with black background: RGB 43 43 43
backgroundColor = Asset.Scene.Compose.toolbarBackground.color
let stackView = UIStackView()

View File

@ -48,7 +48,7 @@ final class ReplicaStatusView: UIView {
let headerIconLabel: UILabel = {
let label = UILabel()
label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
label.attributedText = ReplicaStatusView.iconAttributedString(image: ReplicaStatusView.reblogIconImage)
return label
}()
@ -67,7 +67,6 @@ final class ReplicaStatusView: UIView {
return view
}()
let avatarImageView: UIImageView = FLAnimatedImageView()
let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton()
let nameLabel: ActiveLabel = {
let label = ActiveLabel(style: .statusName)
@ -157,7 +156,7 @@ extension ReplicaStatusView {
headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor),
headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor),
headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor),
headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh),
headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: ReplicaStatusView.containerStackViewSpacing).priority(.defaultHigh),
])
containerStackView.addArrangedSubview(headerContainerView)
defer {
@ -167,15 +166,15 @@ extension ReplicaStatusView {
// author container: [avatar | author meta container | reveal button]
let authorContainerStackView = UIStackView()
authorContainerStackView.axis = .horizontal
authorContainerStackView.spacing = StatusView.avatarToLabelSpacing
authorContainerStackView.spacing = ReplicaStatusView.avatarToLabelSpacing
authorContainerStackView.distribution = .fill
// avatar
avatarView.translatesAutoresizingMaskIntoConstraints = false
authorContainerStackView.addArrangedSubview(avatarView)
NSLayoutConstraint.activate([
avatarView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.required - 1),
avatarView.heightAnchor.constraint(equalToConstant: StatusView.avatarImageSize.height).priority(.required - 1),
avatarView.widthAnchor.constraint(equalToConstant: ReplicaStatusView.avatarImageSize.width).priority(.required - 1),
avatarView.heightAnchor.constraint(equalToConstant: ReplicaStatusView.avatarImageSize.height).priority(.required - 1),
])
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(avatarImageView)
@ -185,14 +184,6 @@ extension ReplicaStatusView {
avatarImageView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
avatarImageView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(avatarStackedContainerButton)
NSLayoutConstraint.activate([
avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor),
avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor),
avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
// author meta container: [title container | subtitle container]
let authorMetaContainerStackView = UIStackView()
@ -235,7 +226,7 @@ extension ReplicaStatusView {
authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor),
authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor),
authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor),
authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh),
authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: ReplicaStatusView.containerStackViewSpacing).priority(.defaultHigh),
])
containerStackView.addArrangedSubview(authorContainerView)
@ -252,8 +243,6 @@ extension ReplicaStatusView {
// status
statusContainerStackView.addArrangedSubview(contentMetaText.textView)
contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
avatarStackedContainerButton.isHidden = true
}
}

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonUI
final class StatusContentWarningEditorView: UIView {
@ -72,28 +73,6 @@ extension StatusContentWarningEditorView {
containerStackView.addArrangedSubview(iconImageView)
iconImageView.setContentHuggingPriority(.required - 1, for: .horizontal)
containerStackView.addArrangedSubview(textView)
// iconImageView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(iconImageView)
// NSLayoutConstraint.activate([
// iconImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
// iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar
// ])
// iconImageView.setContentHuggingPriority(.required - 2, for: .horizontal)
//
// textView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(textView)
// NSLayoutConstraint.activate([
// textView.centerYAnchor.constraint(equalTo: centerYAnchor),
// textView.topAnchor.constraint(equalTo: topAnchor, constant: 6),
// textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addition inset
// textView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
// bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6),
// textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
// ])
//
// textView.setContentHuggingPriority(.required - 1, for: .vertical)
// textView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
}
}

View File

@ -57,10 +57,6 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.showThreadAction(action)
},
UIAction(title: "Show Share Action Compose", image: UIImage(systemName: "square.and.arrow.up"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showShareActionExtensionComposeView(action)
},
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showSettings(action)
@ -370,13 +366,5 @@ extension HomeTimelineViewController {
)
}
@objc private func showShareActionExtensionComposeView(_ sender: UIAction) {
let viewController = UIHostingController(
rootView: ComposeView().environmentObject(MastodonUI.ComposeViewModel())
)
let navigationController = UINavigationController(rootViewController: viewController)
present(navigationController, animated: true, completion: nil)
}
}
#endif

View File

@ -465,7 +465,7 @@ extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
guard let result = results.first else { return }
PHPickerResultLoader.loadImageData(from: result)
ItemProviderLoader.loadImageData(from: result)
.sink { [weak self] completion in
guard let _ = self else { return }
switch completion {

View File

@ -12,6 +12,7 @@ import PhotosUI
import GameplayKit
import MobileCoreServices
import MastodonSDK
import MastodonUI
protocol MastodonAttachmentServiceDelegate: AnyObject {
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?)
@ -62,10 +63,10 @@ final class MastodonAttachmentService {
Just(pickerResult)
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
return PHPickerResultLoader.loadImageData(from: result).eraseToAnyPublisher()
return ItemProviderLoader.loadImageData(from: result).eraseToAnyPublisher()
}
if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
return PHPickerResultLoader.loadVideoData(from: result).eraseToAnyPublisher()
return ItemProviderLoader.loadVideoData(from: result).eraseToAnyPublisher()
}
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
}
@ -186,7 +187,6 @@ extension MastodonAttachmentService {
case invalidAttachmentType
case attachmentTooLarge
}
}
extension MastodonAttachmentService {

View File

@ -7,7 +7,7 @@
import UIKit
protocol Theme {
public protocol Theme {
var systemBackgroundColor: UIColor { get }
var secondarySystemBackgroundColor: UIColor { get }
var tertiarySystemBackgroundColor: UIColor { get }
@ -36,13 +36,13 @@ protocol Theme {
}
enum ThemeName: String, CaseIterable {
public enum ThemeName: String, CaseIterable {
case system
case mastodon
}
extension ThemeName {
var theme: Theme {
public var theme: Theme {
switch self {
case .system: return SystemTheme()
case .mastodon: return MastodonTheme()

View File

@ -0,0 +1,57 @@
//
// ThemeService+Appearance.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-19.
//
import UIKit
extension ThemeService {
func set(themeName: ThemeName) {
UserDefaults.shared.currentThemeNameRawValue = themeName.rawValue
let theme = themeName.theme
apply(theme: theme)
currentTheme.value = theme
}
func apply(theme: Theme) {
// set navigation bar appearance
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
appearance.backgroundColor = theme.navigationBarBackgroundColor
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
// set tab bar appearance
let tabBarAppearance = UITabBarAppearance()
tabBarAppearance.configureWithDefaultBackground()
let tabBarItemAppearance = UITabBarItemAppearance()
tabBarItemAppearance.selected.iconColor = theme.tabBarItemSelectedIconColor
tabBarItemAppearance.focused.iconColor = theme.tabBarItemFocusedIconColor
tabBarItemAppearance.normal.iconColor = theme.tabBarItemNormalIconColor
tabBarItemAppearance.disabled.iconColor = theme.tabBarItemDisabledIconColor
tabBarAppearance.stackedLayoutAppearance = tabBarItemAppearance
tabBarAppearance.inlineLayoutAppearance = tabBarItemAppearance
tabBarAppearance.compactInlineLayoutAppearance = tabBarItemAppearance
tabBarAppearance.backgroundColor = theme.tabBarBackgroundColor
tabBarAppearance.selectionIndicatorTintColor = Asset.Colors.brandBlue.color
UITabBar.appearance().standardAppearance = tabBarAppearance
UITabBar.appearance().barTintColor = theme.tabBarBackgroundColor
// set table view cell appearance
UITableViewCell.appearance().backgroundColor = theme.tableViewCellBackgroundColor
UITableViewCell.appearance(whenContainedInInstancesOf: [SettingsViewController.self]).backgroundColor = theme.secondarySystemGroupedBackgroundColor
UITableViewCell.appearance().selectionColor = theme.tableViewCellSelectionBackgroundColor
// set search bar appearance
UISearchBar.appearance().tintColor = Asset.Colors.brandBlue.color
UISearchBar.appearance().barTintColor = theme.navigationBarBackgroundColor
let cancelButtonAttributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.foregroundColor: Asset.Colors.brandBlue.color]
UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self]).setTitleTextAttributes(cancelButtonAttributes, for: .normal)
}
}

View File

@ -21,52 +21,4 @@ final class ThemeService {
currentTheme = CurrentValueSubject(theme)
}
func set(themeName: ThemeName) {
UserDefaults.shared.currentThemeNameRawValue = themeName.rawValue
let theme = themeName.theme
apply(theme: theme)
currentTheme.value = theme
}
func apply(theme: Theme) {
// set navigation bar appearance
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
appearance.backgroundColor = theme.navigationBarBackgroundColor
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
// set tab bar appearance
let tabBarAppearance = UITabBarAppearance()
tabBarAppearance.configureWithDefaultBackground()
let tabBarItemAppearance = UITabBarItemAppearance()
tabBarItemAppearance.selected.iconColor = theme.tabBarItemSelectedIconColor
tabBarItemAppearance.focused.iconColor = theme.tabBarItemFocusedIconColor
tabBarItemAppearance.normal.iconColor = theme.tabBarItemNormalIconColor
tabBarItemAppearance.disabled.iconColor = theme.tabBarItemDisabledIconColor
tabBarAppearance.stackedLayoutAppearance = tabBarItemAppearance
tabBarAppearance.inlineLayoutAppearance = tabBarItemAppearance
tabBarAppearance.compactInlineLayoutAppearance = tabBarItemAppearance
tabBarAppearance.backgroundColor = theme.tabBarBackgroundColor
tabBarAppearance.selectionIndicatorTintColor = Asset.Colors.brandBlue.color
UITabBar.appearance().standardAppearance = tabBarAppearance
UITabBar.appearance().barTintColor = theme.tabBarBackgroundColor
// set table view cell appearance
UITableViewCell.appearance().backgroundColor = theme.tableViewCellBackgroundColor
UITableViewCell.appearance(whenContainedInInstancesOf: [SettingsViewController.self]).backgroundColor = theme.secondarySystemGroupedBackgroundColor
UITableViewCell.appearance().selectionColor = theme.tableViewCellSelectionBackgroundColor
// set search bar appearance
UISearchBar.appearance().tintColor = Asset.Colors.brandBlue.color
UISearchBar.appearance().barTintColor = theme.navigationBarBackgroundColor
let cancelButtonAttributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.foregroundColor: Asset.Colors.brandBlue.color]
UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self]).setTitleTextAttributes(cancelButtonAttributes, for: .normal)
}
}

View File

@ -10,6 +10,7 @@ import UIKit
import UserNotifications
import AppShared
import AVFoundation
@_exported import MastodonUI
#if ASDK
import AsyncDisplayKit

View File

@ -24,6 +24,8 @@ let package = Package(
.package(url: "https://github.com/apple/swift-nio.git", from: "1.0.0"),
.package(url: "https://github.com/kean/Nuke.git", from: "10.3.1"),
.package(name: "NukeFLAnimatedImagePlugin", url: "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git", from: "8.0.0"),
.package(name: "UITextView+Placeholder", url: "https://github.com/MainasuK/UITextView-Placeholder.git", from: "1.4.1"),
.package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -38,9 +40,12 @@ let package = Package(
.target(
name: "MastodonUI",
dependencies: [
"MastodonSDK",
"MastodonExtension",
"Nuke",
"NukeFLAnimatedImagePlugin"
"NukeFLAnimatedImagePlugin",
"UITextView+Placeholder",
"Introspect",
]
),
.target(

View File

@ -8,12 +8,12 @@
import UIKit
extension NSLayoutConstraint {
func priority(_ priority: UILayoutPriority) -> Self {
public func priority(_ priority: UILayoutPriority) -> Self {
self.priority = priority
return self
}
func identifier(_ identifier: String?) -> Self {
public func identifier(_ identifier: String?) -> Self {
self.identifier = identifier
return self
}

View File

@ -6,11 +6,10 @@
//
import Foundation
import AppShared
extension UserDefaults {
subscript<T: RawRepresentable>(key: String) -> T? {
public subscript<T: RawRepresentable>(key: String) -> T? {
get {
if let rawValue = value(forKey: key) as? T.RawValue {
return T(rawValue: rawValue)
@ -20,7 +19,7 @@ extension UserDefaults {
set { set(newValue?.rawValue, forKey: key) }
}
subscript<T>(key: String) -> T? {
public subscript<T>(key: String) -> T? {
get { return value(forKey: key) as? T }
set { set(newValue, forKey: key) }
}

View File

@ -1,68 +0,0 @@
//
// ComposeView.swift
//
//
// Created by MainasuK Cirno on 2021-7-16.
//
import SwiftUI
public struct ComposeView: View {
@EnvironmentObject public var viewModel: ComposeViewModel
public init() { }
public var body: some View {
GeometryReader { proxy in
ScrollView(.vertical) {
StatusAuthorView(
avatarImageURL: viewModel.avatarImageURL,
name: viewModel.authorName,
username: viewModel.authorUsername
)
TextEditorView(
string: $viewModel.statusContent,
width: viewModel.frame.width,
attributedString: viewModel.statusContentAttributedString
)
.frame(width: viewModel.frame.width)
.frame(minHeight: 100)
ForEach(viewModel.attachments, id: \.self) { image in
Image(uiImage: image)
.resizable()
.aspectRatio(16.0/9.0, contentMode: .fill)
.frame(maxWidth: .infinity)
.background(Color.gray)
.cornerRadius(4)
}
} // end ScrollView
.preference(
key: ComposeViewFramePreferenceKey.self,
value: proxy.frame(in: .local)
)
.onPreferenceChange(ComposeViewFramePreferenceKey.self) { frame in
viewModel.frame = frame
print(frame)
}
}
}
}
struct ComposeViewFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { }
}
struct ComposeView_Previews: PreviewProvider {
static let viewModel: ComposeViewModel = {
let viewModel = ComposeViewModel()
return viewModel
}()
static var previews: some View {
ComposeView().environmentObject(viewModel)
}
}

View File

@ -1,46 +0,0 @@
//
// ComposeViewModel.swift
// ShareActionExtension
//
// Created by MainasuK Cirno on 2021-7-16.
//
import Foundation
import SwiftUI
import Combine
public class ComposeViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
@Published var frame: CGRect = .zero
@Published var avatarImageURL: URL?
@Published var authorName: String = ""
@Published var authorUsername: String = ""
@Published var statusContent = ""
@Published var statusContentAttributedString = NSAttributedString()
@Published var contentWarningContent = ""
@Published var attachments: [UIImage] = []
public init() {
$statusContent
.map { NSAttributedString(string: $0) }
.assign(to: &$statusContentAttributedString)
#if DEBUG
avatarImageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif")
authorName = "Alice"
authorUsername = "alice"
attachments = [
UIImage(systemName: "photo")!,
UIImage(systemName: "photo")!,
UIImage(systemName: "photo")!,
UIImage(systemName: "photo")!,
]
#endif
}
}

View File

@ -8,7 +8,7 @@
import UIKit
import Combine
final class KeyboardResponderService {
final public class KeyboardResponderService {
var disposeBag = Set<AnyCancellable>()
@ -16,9 +16,9 @@ final class KeyboardResponderService {
public static let shared = KeyboardResponderService()
// output
let isShow = CurrentValueSubject<Bool, Never>(false)
let state = CurrentValueSubject<KeyboardState, Never>(.none)
let endFrame = CurrentValueSubject<CGRect, Never>(.zero)
public let isShow = CurrentValueSubject<Bool, Never>(false)
public let state = CurrentValueSubject<KeyboardState, Never>(.none)
public let endFrame = CurrentValueSubject<CGRect, Never>(.zero)
private init() {
NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification, object: nil)
@ -82,7 +82,7 @@ extension KeyboardResponderService {
}
extension KeyboardResponderService {
enum KeyboardState {
public enum KeyboardState {
case none
case notLocal
case notDock // undock | split

View File

@ -9,23 +9,27 @@ import SwiftUI
import Nuke
import FLAnimatedImage
struct AnimatedImage: UIViewRepresentable {
public struct AnimatedImage: UIViewRepresentable {
let imageURL: URL?
public let imageURL: URL?
func makeUIView(context: Context) -> FLAnimatedImageViewProxy {
public init(imageURL: URL?) {
self.imageURL = imageURL
}
public func makeUIView(context: Context) -> FLAnimatedImageViewProxy {
let proxy = FLAnimatedImageViewProxy(frame: .zero)
Nuke.loadImage(with: imageURL, into: proxy.imageView)
return proxy
}
func updateUIView(_ proxy: FLAnimatedImageViewProxy, context: Context) {
public func updateUIView(_ proxy: FLAnimatedImageViewProxy, context: Context) {
Nuke.cancelRequest(for: proxy.imageView)
Nuke.loadImage(with: imageURL, into: proxy.imageView)
}
}
final class FLAnimatedImageViewProxy: UIView {
final public class FLAnimatedImageViewProxy: UIView {
let imageView = FLAnimatedImageView()
override init(frame: CGRect) {

View File

@ -1,6 +1,6 @@
//
// PHPickerResultLoader.swift
// Mastodon
// ItemProviderLoader.swift
// MastodonUI
//
// Created by MainasuK Cirno on 2021-3-18.
//
@ -14,11 +14,19 @@ import MastodonSDK
// load image with low memory usage
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
enum PHPickerResultLoader {
public enum ItemProviderLoader {
static let logger = Logger(subsystem: "ItemProviderLoader", category: "logic")
}
extension ItemProviderLoader {
public static func loadImageData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
loadImageData(from: result.itemProvider)
}
static func loadImageData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
public static func loadImageData(from itemProvider: NSItemProvider) -> Future<Mastodon.Query.MediaAttachment?, Error> {
Future { promise in
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
if let error = error {
promise(.failure(error))
return
@ -63,17 +71,25 @@ enum PHPickerResultLoader {
CGImageDestinationFinalize(imageDestination)
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize)
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load image \(dataSize)")
let file = Mastodon.Query.MediaAttachment.jpeg(data as Data)
promise(.success(file))
}
}
}
static func loadVideoData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
}
extension ItemProviderLoader {
public static func loadVideoData(from result: PHPickerResult) -> Future<Mastodon.Query.MediaAttachment?, Error> {
loadVideoData(from: result.itemProvider)
}
public static func loadVideoData(from itemProvider: NSItemProvider) -> Future<Mastodon.Query.MediaAttachment?, Error> {
Future { promise in
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let error = error {
promise(.failure(error))
return

View File

@ -10,16 +10,16 @@ import Foundation
extension String {
@inlinable
var length: Int {
public var length: Int {
(self as NSString).length
}
@inlinable
func substring(with range: NSRange) -> String {
public func substring(with range: NSRange) -> String {
(self as NSString).substring(with: range)
}
func substring(with result: NSTextCheckingResult, at index: Int) -> String? {
public func substring(with result: NSTextCheckingResult, at index: Int) -> String? {
guard index < result.numberOfRanges else {
return nil
}
@ -30,7 +30,7 @@ extension String {
return substring(with: result.range(at: index))
}
func firstMatch(pattern: String,
public func firstMatch(pattern: String,
options: NSRegularExpression.Options = [],
range: NSRange? = nil) -> NSTextCheckingResult?
{
@ -41,7 +41,7 @@ extension String {
return regularExpression.firstMatch(in: self, options: [], range: range)
}
func matches(pattern: String,
public func matches(pattern: String,
options: NSRegularExpression.Options = [],
range: NSRange? = nil) -> [NSTextCheckingResult]
{

View File

@ -7,25 +7,25 @@
import UIKit
final class HighlightDimmableButton: UIButton {
final public class HighlightDimmableButton: UIButton {
var expandEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
public var expandEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
override init(frame: CGRect) {
public override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return bounds.inset(by: expandEdgeInsets).contains(point)
}
override var isHighlighted: Bool {
public override var isHighlighted: Bool {
didSet {
alpha = isHighlighted ? 0.6 : 1
}

View File

@ -34,6 +34,11 @@ target 'NotificationService' do
use_frameworks!
end
target 'ShareActionExtension' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
end
target 'AppShared' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!

View File

@ -78,6 +78,6 @@ SPEC CHECKSUMS:
Texture: 2f109e937850d94d1d07232041c9c7313ccddb81
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
PODFILE CHECKSUM: f2f99b5771c5c36ef69d13999b88cea5b0e8bfe1
PODFILE CHECKSUM: adf1bf30957525fcafb99001323d1c6ad9995b9d
COCOAPODS: 1.10.1

View File

@ -0,0 +1,228 @@
//
// ShareViewController.swift
// MastodonShareAction
//
// Created by MainasuK Cirno on 2021-7-16.
//
import os.log
import UIKit
import Combine
import MastodonUI
import SwiftUI
class ShareViewController: UIViewController {
let logger = Logger(subsystem: "ShareViewController", category: "UI")
var disposeBag = Set<AnyCancellable>()
let viewModel = ShareViewModel()
let publishButton: UIButton = {
let button = RoundedEdgesButton(type: .custom)
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
button.setBackgroundImage(.placeholder(color: Asset.Colors.brandBlue.color), for: .normal)
button.setBackgroundImage(.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .highlighted)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
button.setTitleColor(.white, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
button.adjustsImageWhenHighlighted = false
return button
}()
private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ShareViewController.cancelBarButtonItemPressed(_:)))
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(customView: publishButton)
barButtonItem.target = self
barButtonItem.action = #selector(ShareViewController.publishBarButtonItemPressed(_:))
return barButtonItem
}()
let activityIndicatorBarButtonItem: UIBarButtonItem = {
let indicatorView = UIActivityIndicatorView(style: .medium)
let barButtonItem = UIBarButtonItem(customView: indicatorView)
indicatorView.startAnimating()
return barButtonItem
}()
let viewSafeAreaDidChange = PassthroughSubject<Void, Never>()
let composeToolbarView = ComposeToolbarView()
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
let composeToolbarBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
return view
}()
}
extension ShareViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemBackground.color
navigationItem.leftBarButtonItem = cancelBarButtonItem
viewModel.isBusy
.receive(on: DispatchQueue.main)
.sink { [weak self] isBusy in
guard let self = self else { return }
self.navigationItem.rightBarButtonItem = isBusy ? self.activityIndicatorBarButtonItem : self.publishBarButtonItem
}
.store(in: &disposeBag)
let hostingViewController = UIHostingController(
rootView: ComposeView().environmentObject(viewModel.composeViewModel)
)
addChild(hostingViewController)
view.addSubview(hostingViewController.view)
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingViewController.view)
NSLayoutConstraint.activate([
hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingViewController.didMove(toParent: self)
composeToolbarView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(composeToolbarView)
composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor)
NSLayoutConstraint.activate([
composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
composeToolbarViewBottomLayoutConstraint,
composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight),
])
composeToolbarView.preservesSuperviewLayoutMargins = true
composeToolbarView.delegate = self
composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView)
NSLayoutConstraint.activate([
composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor),
composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor),
composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor),
view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor),
])
// FIXME: using iOS 15 toolbar for .keyboard placement
let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state,
KeyboardResponderService.shared.endFrame
)
Publishers.CombineLatest(
keyboardEventPublishers,
viewSafeAreaDidChange
)
.sink(receiveValue: { [weak self] keyboardEvents, _ in
guard let self = self else { return }
let (isShow, state, endFrame) = keyboardEvents
guard isShow, state == .dock else {
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
self.view.layoutIfNeeded()
}
return
}
// isShow AND dock state
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height
self.view.layoutIfNeeded()
}
})
.store(in: &disposeBag)
// bind visibility toolbar UI
Publishers.CombineLatest(
viewModel.selectedStatusVisibility,
viewModel.traitCollectionDidChangePublisher
)
.receive(on: DispatchQueue.main)
.sink { [weak self] type, _ in
guard let self = self else { return }
let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle)
self.composeToolbarView.visibilityButton.setImage(image, for: .normal)
self.composeToolbarView.activeVisibilityType.value = type
}
.store(in: &disposeBag)
// bind counter
viewModel.characterCount
.receive(on: DispatchQueue.main)
.sink { [weak self] characterCount in
guard let self = self else { return }
let count = ShareViewModel.composeContentLimit - characterCount
self.composeToolbarView.characterCountLabel.text = "\(count)"
switch count {
case _ where count < 0:
self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold)
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color
self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitExceedsCount(abs(count))
default:
self.composeToolbarView.characterCountLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular)
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color
self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(count)
}
}
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
viewModel.inputItems.value = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
viewSafeAreaDidChange.send()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
viewModel.traitCollectionDidChangePublisher.send()
}
}
extension ShareViewController {
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
extensionContext?.cancelRequest(withError: ShareViewModel.ShareError.userCancelShare)
}
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}
// MARK - ComposeToolbarViewDelegate
extension ShareViewController: ComposeToolbarViewDelegate {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
withAnimation {
viewModel.composeViewModel.isContentWarningComposing.toggle()
}
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
viewModel.selectedStatusVisibility.value = type
}
}

View File

@ -11,6 +11,8 @@ import Combine
import CoreData
import CoreDataStack
import MastodonUI
import SwiftUI
import UniformTypeIdentifiers
final class ShareViewModel {
@ -18,10 +20,15 @@ final class ShareViewModel {
var disposeBag = Set<AnyCancellable>()
static let composeContentLimit: Int = 500
// input
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
private var coreDataStack: CoreDataStack?
var managedObjectContext: NSManagedObjectContext?
var inputItems = CurrentValueSubject<[NSExtensionItem], Never>([])
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
let selectedStatusVisibility = CurrentValueSubject<ComposeToolbarView.VisibilitySelectionType, Never>(.public)
// output
let authentication = CurrentValueSubject<Result<MastodonAuthentication, Error>?, Never>(nil)
@ -29,6 +36,7 @@ final class ShareViewModel {
let isBusy = CurrentValueSubject<Bool, Never>(true)
let isValid = CurrentValueSubject<Bool, Never>(false)
let composeViewModel = ComposeViewModel()
let characterCount = CurrentValueSubject<Int, Never>(0)
init() {
viewDidAppear.receive(on: DispatchQueue.main)
@ -40,15 +48,63 @@ final class ShareViewModel {
}
.store(in: &disposeBag)
Publishers.CombineLatest(
inputItems.removeDuplicates(),
viewDidAppear.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] inputItems, _ in
guard let self = self else { return }
self.parse(inputItems: inputItems)
}
.store(in: &disposeBag)
authentication
.map { result in result == nil }
.assign(to: \.value, on: isFetchAuthentication)
.store(in: &disposeBag)
authentication
.compactMap { result -> Bool? in
guard let result = result else { return nil }
switch result {
case .success(let authentication):
return authentication.user.locked
case .failure:
return nil
}
}
.map { locked -> ComposeToolbarView.VisibilitySelectionType in
locked ? .private : .public
}
.assign(to: \.value, on: selectedStatusVisibility)
.store(in: &disposeBag)
isFetchAuthentication
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: isBusy)
.store(in: &disposeBag)
composeViewModel.statusPlaceholder = L10n.Scene.Compose.contentInputPlaceholder
composeViewModel.contentWarningPlaceholder = L10n.Scene.Compose.ContentWarning.placeholder
composeViewModel.toolbarHeight = ComposeToolbarView.toolbarHeight
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
composeViewModel.$characterCount
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
}
private func setupBackgroundColor(theme: Theme) {
composeViewModel.contentWarningBackgroundColor = Color(theme.contentWarningOverlayBackgroundColor)
}
}
@ -99,3 +155,32 @@ extension ShareViewModel {
}
}
}
extension ShareViewModel {
func parse(inputItems: [NSExtensionItem]) {
var itemProviders: [NSItemProvider] = []
for item in inputItems {
itemProviders.append(contentsOf: item.attachments ?? [])
}
let _movieProvider = itemProviders.first(where: { provider in
return provider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: [])
})
let imageProviders = itemProviders.filter { provider in
return provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: [])
}
if let movieProvider = _movieProvider {
composeViewModel.setupAttachmentViewModels([
StatusAttachmentViewModel(itemProvider: movieProvider)
])
} else {
let viewModels = imageProviders.map { provider in
StatusAttachmentViewModel(itemProvider: provider)
}
composeViewModel.setupAttachmentViewModels(viewModels)
}
}
}

View File

@ -0,0 +1,246 @@
//
// ComposeToolbarView.swift
// ShareActionExtension
//
// Created by MainasuK Cirno on 2021-7-19.
//
import os.log
import UIKit
import Combine
import MastodonSDK
import MastodonUI
protocol ComposeToolbarViewDelegate: AnyObject {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType)
}
final class ComposeToolbarView: UIView {
var disposeBag = Set<AnyCancellable>()
static let toolbarButtonSize: CGSize = CGSize(width: 44, height: 44)
static let toolbarHeight: CGFloat = 44
weak var delegate: ComposeToolbarViewDelegate?
let contentWarningButton: UIButton = {
let button = HighlightDimmableButton()
ComposeToolbarView.configureToolbarButtonAppearance(button: button)
button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal)
button.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning
return button
}()
let visibilityButton: UIButton = {
let button = HighlightDimmableButton()
ComposeToolbarView.configureToolbarButtonAppearance(button: button)
button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
button.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu
return button
}()
let characterCountLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .regular)
label.text = "500"
label.textColor = Asset.Colors.Label.secondary.color
label.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(500)
return label
}()
let activeVisibilityType = CurrentValueSubject<VisibilitySelectionType, Never>(.public)
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeToolbarView {
private func _init() {
backgroundColor = Asset.Scene.Compose.toolbarBackground.color
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 0
stackView.distribution = .fillEqually
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
layoutMarginsGuide.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 8), // tweak button margin offset
])
let buttons = [
contentWarningButton,
visibilityButton,
]
buttons.forEach { button in
button.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(button)
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 44),
button.heightAnchor.constraint(equalToConstant: 44),
])
}
characterCountLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(characterCountLabel)
NSLayoutConstraint.activate([
characterCountLabel.topAnchor.constraint(equalTo: topAnchor),
characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8),
characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
])
characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside)
visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle)
visibilityButton.showsMenuAsPrimaryAction = true
updateToolbarButtonUserInterfaceStyle()
// update menu when selected visibility type changed
activeVisibilityType
.receive(on: RunLoop.main)
.sink { [weak self] type in
guard let self = self else { return }
self.visibilityButton.menu = self.createVisibilityContextMenu(interfaceStyle: self.traitCollection.userInterfaceStyle)
}
.store(in: &disposeBag)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateToolbarButtonUserInterfaceStyle()
}
}
extension ComposeToolbarView {
enum MediaSelectionType: String {
case camera
case photoLibrary
case browse
}
enum VisibilitySelectionType: String, CaseIterable {
case `public`
// TODO: remove unlisted option from codebase
// case unlisted
case `private`
case direct
var title: String {
switch self {
case .public: return L10n.Scene.Compose.Visibility.public
// case .unlisted: return L10n.Scene.Compose.Visibility.unlisted
case .private: return L10n.Scene.Compose.Visibility.private
case .direct: return L10n.Scene.Compose.Visibility.direct
}
}
func image(interfaceStyle: UIUserInterfaceStyle) -> UIImage {
switch self {
case .public: return UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .medium))!
// case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))!
case .private:
switch interfaceStyle {
case .light: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
default: return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
}
case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .regular))!
}
}
var visibility: Mastodon.Entity.Status.Visibility {
switch self {
case .public: return .public
// case .unlisted: return .unlisted
case .private: return .private
case .direct: return .direct
}
}
}
}
extension ComposeToolbarView {
private static func configureToolbarButtonAppearance(button: UIButton) {
button.tintColor = Asset.Colors.brandBlue.color
button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted)
button.layer.masksToBounds = true
button.layer.cornerRadius = 5
button.layer.cornerCurve = .continuous
}
private func updateToolbarButtonUserInterfaceStyle() {
switch traitCollection.userInterfaceStyle {
case .light:
contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
case .dark:
contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
default:
assertionFailure()
}
visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle)
}
private func createVisibilityContextMenu(interfaceStyle: UIUserInterfaceStyle) -> UIMenu {
let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in
let state: UIMenuElement.State = activeVisibilityType.value == type ? .on : .off
return UIAction(title: type.title, image: type.image(interfaceStyle: interfaceStyle), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { [weak self] action in
guard let self = self else { return }
os_log(.info, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue)
self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type)
}
}
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
}
extension ComposeToolbarView {
@objc private func contentWarningButtonDidPressed(_ sender: UIButton) {
os_log(.info, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender)
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct ComposeToolbarView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
let toolbarView = ComposeToolbarView()
toolbarView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh),
toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh),
])
return toolbarView
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -0,0 +1,117 @@
//
// ComposeView.swift
//
//
// Created by MainasuK Cirno on 2021-7-16.
//
import UIKit
import SwiftUI
public struct ComposeView: View {
@EnvironmentObject var viewModel: ComposeViewModel
@State var statusEditorViewWidth: CGFloat = .zero
let horizontalMargin: CGFloat = 20
public init() { }
public var body: some View {
GeometryReader { proxy in
List {
// Content Warning
if viewModel.isContentWarningComposing {
ContentWarningEditorView(
contentWarningContent: $viewModel.contentWarningContent,
placeholder: viewModel.contentWarningPlaceholder
)
.padding(EdgeInsets(top: 6, leading: horizontalMargin, bottom: 6, trailing: horizontalMargin))
.background(viewModel.contentWarningBackgroundColor)
.transition(.opacity)
.listRow()
}
// Author
StatusAuthorView(
avatarImageURL: viewModel.avatarImageURL,
name: viewModel.authorName,
username: viewModel.authorUsername
)
.padding(EdgeInsets(top: 20, leading: horizontalMargin, bottom: 16, trailing: horizontalMargin))
.listRow()
// Editor
StatusEditorView(
string: $viewModel.statusContent,
placeholder: viewModel.statusPlaceholder,
width: statusEditorViewWidth,
attributedString: viewModel.statusContentAttributedString,
keyboardType: .twitter
)
.frame(width: statusEditorViewWidth)
.frame(minHeight: 100)
.padding(EdgeInsets(top: 0, leading: horizontalMargin, bottom: 0, trailing: horizontalMargin))
.listRow()
// Attachments
ForEach(viewModel.attachmentViewModels) { viewModel in
StatusAttachmentView(
image: viewModel.thumbnailImage,
removeButtonAction: {
self.viewModel.removeAttachmentViewModel(viewModel)
}
)
}
.padding(EdgeInsets(top: 16, leading: horizontalMargin, bottom: 0, trailing: horizontalMargin))
.fixedSize(horizontal: false, vertical: true)
.listRow()
// bottom padding
Color.clear
.frame(height: viewModel.toolbarHeight + 20)
.listRow()
} // end List
.introspectTableView(customize: { tableView in
tableView.keyboardDismissMode = .onDrag
tableView.verticalScrollIndicatorInsets.bottom = viewModel.toolbarHeight
})
.preference(
key: ComposeListViewFramePreferenceKey.self,
value: proxy.frame(in: .local)
)
.onPreferenceChange(ComposeListViewFramePreferenceKey.self) { frame in
var frame = frame
frame.size.width = frame.width - 2 * horizontalMargin
statusEditorViewWidth = frame.width
}
}
}
}
struct ComposeListViewFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { }
}
extension View {
func listRow() -> some View {
self.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1))
.background(Color(.systemBackground))
}
}
struct ComposeView_Previews: PreviewProvider {
static let viewModel: ComposeViewModel = {
let viewModel = ComposeViewModel()
return viewModel
}()
static var previews: some View {
ComposeView().environmentObject(viewModel)
}
}

View File

@ -0,0 +1,80 @@
//
// ComposeViewModel.swift
// ShareActionExtension
//
// Created by MainasuK Cirno on 2021-7-16.
//
import Foundation
import SwiftUI
import Combine
class ComposeViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
@Published var toolbarHeight: CGFloat = 0
@Published var avatarImageURL: URL?
@Published var authorName: String = ""
@Published var authorUsername: String = ""
@Published var statusContent = ""
@Published var statusPlaceholder = ""
@Published var statusContentAttributedString = NSAttributedString()
@Published var isContentWarningComposing = false
@Published var contentWarningBackgroundColor = Color.secondary
@Published var contentWarningPlaceholder = ""
@Published var contentWarningContent = ""
@Published private(set) var attachmentViewModels: [StatusAttachmentViewModel] = []
@Published var characterCount = 0
public init() {
$statusContent
.map { NSAttributedString(string: $0) }
.assign(to: &$statusContentAttributedString)
Publishers.CombineLatest3(
$statusContent,
$isContentWarningComposing,
$contentWarningContent
)
.map { statusContent, isContentWarningComposing, contentWarningContent in
var count = statusContent.count
if isContentWarningComposing {
count += contentWarningContent.count
}
return count
}
.assign(to: &$characterCount)
#if DEBUG
avatarImageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif")
authorName = "Alice"
authorUsername = "alice"
#endif
}
}
extension ComposeViewModel {
func setupAttachmentViewModels(_ viewModels: [StatusAttachmentViewModel]) {
attachmentViewModels = viewModels
for viewModel in viewModels {
viewModel.objectWillChange.sink { [weak self] _ in
guard let self = self else { return }
self.objectWillChange.send()
}
.store(in: &viewModel.disposeBag)
}
}
func removeAttachmentViewModel(_ viewModel: StatusAttachmentViewModel) {
if let index = attachmentViewModels.firstIndex(where: { $0 === viewModel }) {
attachmentViewModels.remove(at: index)
}
}
}

View File

@ -0,0 +1,48 @@
//
// ContentWarningEditorView.swift
//
//
// Created by MainasuK Cirno on 2021-7-19.
//
import SwiftUI
import Introspect
struct ContentWarningEditorView: View {
@Binding var contentWarningContent: String
let placeholder: String
let spacing: CGFloat = 11
var body: some View {
HStack(alignment: .center, spacing: spacing) {
Image(systemName: "exclamationmark.shield")
.font(.system(size: 30, weight: .regular))
Text(contentWarningContent.isEmpty ? " " : contentWarningContent)
.opacity(0)
.padding(.all, 8)
.frame(maxWidth: .infinity)
.overlay(
TextEditor(text: $contentWarningContent)
.introspectTextView { textView in
textView.backgroundColor = .clear
textView.placeholder = placeholder
}
)
}
}
}
struct ContentWarningEditorView_Previews: PreviewProvider {
@State static var content = ""
static var previews: some View {
ContentWarningEditorView(
contentWarningContent: $content,
placeholder: "Write an accurate warning here..."
)
.previewLayout(.fixed(width: 375, height: 100))
}
}

View File

@ -0,0 +1,65 @@
//
// StatusAttachmentView.swift
//
//
// Created by MainasuK Cirno on 2021-7-19.
//
import SwiftUI
struct StatusAttachmentView: View {
let image: UIImage?
let removeButtonAction: () -> Void
var body: some View {
let image = image ?? UIImage.placeholder(color: .systemFill)
Color.clear
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill)
.overlay(
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
)
.background(Color.gray)
.cornerRadius(4)
.badgeView(
Button(action: {
removeButtonAction()
}, label: {
Image(systemName: "minus.circle.fill")
.renderingMode(.original)
.font(.system(size: 22, weight: .bold, design: .default))
})
.buttonStyle(BorderlessButtonStyle())
)
}
}
extension View {
func badgeView<Content>(_ content: Content) -> some View where Content: View {
overlay(
ZStack {
content
}
.alignmentGuide(.top) { $0.height / 2 }
.alignmentGuide(.trailing) { $0.width / 2 }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
)
}
}
struct StatusAttachmentView_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
StatusAttachmentView(
image: UIImage(systemName: "photo"),
removeButtonAction: {
// do nothing
}
)
.padding(20)
}
}
}

View File

@ -0,0 +1,104 @@
//
// StatusAttachmentViewModel.swift
// ShareActionExtension
//
// Created by MainasuK Cirno on 2021-7-19.
//
import os.log
import Foundation
import SwiftUI
import Combine
import MastodonSDK
import MastodonUI
import AVFoundation
import MobileCoreServices
import UniformTypeIdentifiers
final class StatusAttachmentViewModel: ObservableObject, Identifiable {
let logger = Logger(subsystem: "StatusAttachmentViewModel", category: "logic")
var disposeBag = Set<AnyCancellable>()
let id = UUID()
let itemProvider: NSItemProvider
// input
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
@Published var description = ""
// output
@Published var thumbnailImage: UIImage?
@Published var error: Error?
init(itemProvider: NSItemProvider) {
self.itemProvider = itemProvider
Just(itemProvider)
.receive(on: DispatchQueue.main)
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
return ItemProviderLoader.loadImageData(from: result).eraseToAnyPublisher()
}
if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
return ItemProviderLoader.loadVideoData(from: result).eraseToAnyPublisher()
}
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
}
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
self.error = error
// self.uploadStateMachine.enter(UploadState.Fail.self)
case .finished:
break
}
} receiveValue: { [weak self] file in
guard let self = self else { return }
self.file.value = file
// self.uploadStateMachine.enter(UploadState.Initial.self)
}
.store(in: &disposeBag)
file
.receive(on: DispatchQueue.main)
.map { file -> UIImage? in
guard let file = file else {
return nil
}
switch file {
case .jpeg(let data), .png(let data):
return data.flatMap { UIImage(data: $0) }
case .gif:
// TODO:
return nil
case .other(let url, _, _):
guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return nil }
let asset = AVURLAsset(url: url)
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
do {
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
let image = UIImage(cgImage: cgImage)
return image
} catch {
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
return nil
}
}
}
.assign(to: &$thumbnailImage)
}
}
extension StatusAttachmentViewModel {
enum AttachmentError: Error {
case invalidAttachmentType
case attachmentTooLarge
}
}

View File

@ -6,6 +6,7 @@
//
import SwiftUI
import MastodonUI
import Nuke
import NukeFLAnimatedImagePlugin
import FLAnimatedImage

View File

@ -1,45 +1,50 @@
//
// TextEditorView.swift
//
// StatusEditorView.swift
//
//
// Created by MainasuK Cirno on 2021-7-16.
//
import UIKit
import SwiftUI
import UITextView_Placeholder
public struct TextEditorView: UIViewRepresentable {
public struct StatusEditorView: UIViewRepresentable {
@Binding var string: String
let placeholder: String
let width: CGFloat
let attributedString: NSAttributedString
let keyboardType: UIKeyboardType
public init(
string: Binding<String>,
placeholder: String,
width: CGFloat,
attributedString: NSAttributedString
attributedString: NSAttributedString,
keyboardType: UIKeyboardType
) {
self._string = string
self.placeholder = placeholder
self.width = width
self.attributedString = attributedString
self.keyboardType = keyboardType
}
public func makeUIView(context: Context) -> UITextView {
let textView = UITextView(frame: .zero)
textView.placeholder = placeholder
textView.isScrollEnabled = false
textView.font = .preferredFont(forTextStyle: .body)
textView.textColor = .label
textView.keyboardType = keyboardType
textView.delegate = context.coordinator
textView.translatesAutoresizingMaskIntoConstraints = false
let widthLayoutConstraint = textView.widthAnchor.constraint(equalToConstant: 100)
widthLayoutConstraint.priority = .required - 1
context.coordinator.widthLayoutConstraint = widthLayoutConstraint
return textView
}
@ -57,10 +62,10 @@ public struct TextEditorView: UIViewRepresentable {
}
public class Coordinator: NSObject, UITextViewDelegate {
var parent: TextEditorView
var parent: StatusEditorView
var widthLayoutConstraint: NSLayoutConstraint?
init(_ parent: TextEditorView) {
init(_ parent: StatusEditorView) {
self.parent = parent
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.joinmastodon.app</string>
</array>
</dict>
</plist>

View File

@ -1,107 +0,0 @@
//
// ShareViewController.swift
// MastodonShareAction
//
// Created by MainasuK Cirno on 2021-7-16.
//
import os.log
import UIKit
import Combine
import MastodonUI
import SwiftUI
class ShareViewController: UIViewController {
let logger = Logger(subsystem: "ShareViewController", category: "UI")
var disposeBag = Set<AnyCancellable>()
let viewModel = ShareViewModel()
let publishButton: UIButton = {
let button = RoundedEdgesButton(type: .custom)
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
button.setBackgroundImage(.placeholder(color: Asset.Colors.brandBlue.color), for: .normal)
button.setBackgroundImage(.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .highlighted)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
button.setTitleColor(.white, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
button.adjustsImageWhenHighlighted = false
return button
}()
private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ShareViewController.cancelBarButtonItemPressed(_:)))
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(customView: publishButton)
barButtonItem.target = self
barButtonItem.action = #selector(ShareViewController.publishBarButtonItemPressed(_:))
return barButtonItem
}()
let activityIndicatorBarButtonItem: UIBarButtonItem = {
let indicatorView = UIActivityIndicatorView(style: .medium)
let barButtonItem = UIBarButtonItem(customView: indicatorView)
indicatorView.startAnimating()
return barButtonItem
}()
}
extension ShareViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.systemBackground.color
navigationItem.leftBarButtonItem = cancelBarButtonItem
viewModel.isBusy
.receive(on: DispatchQueue.main)
.sink { [weak self] isBusy in
guard let self = self else { return }
self.navigationItem.rightBarButtonItem = isBusy ? self.activityIndicatorBarButtonItem : self.publishBarButtonItem
}
.store(in: &disposeBag)
let hostingViewController = UIHostingController(
rootView: ComposeView().environmentObject(viewModel.composeViewModel)
)
addChild(hostingViewController)
view.addSubview(hostingViewController.view)
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingViewController.view)
NSLayoutConstraint.activate([
hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingViewController.didMove(toParent: self)
// viewModel.authentication
// .receive(on: DispatchQueue.main)
// .sink { [weak self] result in
// guard let self = self else { return }
// }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
// extensionContext
}
}
extension ShareViewController {
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
extensionContext?.cancelRequest(withError: ShareViewModel.ShareError.userCancelShare)
}
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}