feat: make UI works
This commit is contained in:
parent
079e611f33
commit
1cdbd7fa2a
File diff suppressed because it is too large
Load Diff
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -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 ? "" : "-"
|
||||
|
||||
|
|
|
@ -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_]+)"
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonExtension
|
||||
|
||||
extension UserDefaults {
|
||||
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
//
|
||||
// SplashPreference.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2020-2-4.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UserDefaults {
|
||||
// TODO: splash scene
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonExtension
|
||||
|
||||
extension UserDefaults {
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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: " ")
|
|
@ -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
|
||||
|
|
|
@ -16,7 +16,7 @@ import MetaTextView
|
|||
|
||||
extension ComposeViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
func setupDataSource(
|
||||
tableView: UITableView,
|
||||
metaTextDelegate: MetaTextDelegate,
|
||||
metaTextViewDelegate: UITextViewDelegate,
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonUI
|
||||
|
||||
extension AttachmentContainerView {
|
||||
final class EmptyStateView: UIView {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import UserNotifications
|
||||
import AppShared
|
||||
import AVFoundation
|
||||
@_exported import MastodonUI
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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) }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
|
@ -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) {
|
|
@ -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
|
|
@ -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]
|
||||
{
|
|
@ -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
|
||||
}
|
5
Podfile
5
Podfile
|
@ -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!
|
||||
|
|
|
@ -78,6 +78,6 @@ SPEC CHECKSUMS:
|
|||
Texture: 2f109e937850d94d1d07232041c9c7313ccddb81
|
||||
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
||||
|
||||
PODFILE CHECKSUM: f2f99b5771c5c36ef69d13999b88cea5b0e8bfe1
|
||||
PODFILE CHECKSUM: adf1bf30957525fcafb99001323d1c6ad9995b9d
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import MastodonUI
|
||||
import Nuke
|
||||
import NukeFLAnimatedImagePlugin
|
||||
import FLAnimatedImage
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
|
@ -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)")
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue