diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4ee0ebeb7..9b6674434 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -126,6 +126,15 @@ "version" : "5.12.5" } }, + { + "identity" : "stripes", + "kind" : "remoteSourceControl", + "location" : "https://github.com/eneko/Stripes.git", + "state" : { + "revision" : "d533fd44b8043a3abbf523e733599173d6f98c11", + "version" : "0.2.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index ac968754d..0d3e562c0 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -48,6 +48,7 @@ let package = Package( .package(url: "https://github.com/vtourraine/ThirdPartyMailer.git", from: "2.1.0"), .package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"), .package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"), + .package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -122,6 +123,7 @@ let package = Package( .product(name: "MetaTextKit", package: "MetaTextKit"), .product(name: "CropViewController", package: "TOCropViewController"), .product(name: "PanModal", package: "PanModal"), + .product(name: "Stripes", package: "Stripes"), ] ), .testTarget( diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 09553d0f9..3417ed935 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -260,6 +260,9 @@ extension ComposeContentViewController { viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive) viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive) viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive) + viewModel.$maxTextInputLimit.assign(to: &composeContentToolbarViewModel.$maxTextInputLimit) + viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength) + viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength) } } @@ -385,6 +388,16 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate { self.viewModel.isEmojiActive.toggle() case .contentWarning: self.viewModel.isContentWarningActive.toggle() + if self.viewModel.isContentWarningActive { + Task { @MainActor in + try? await Task.sleep(nanoseconds: .second / 20) // 0.05s + self.viewModel.setContentWarningTextViewFirstResponderIfNeeds() + } // end Task + } else { + if self.viewModel.contentWarningMetaText?.textView.isFirstResponder == true { + self.viewModel.setContentTextViewFirstResponderIfNeeds() + } + } case .visibility: assertionFailure() } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift new file mode 100644 index 000000000..80cc033e8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+MetaTextDelegate.swift @@ -0,0 +1,57 @@ +// +// ComposeContentViewModel+MetaTextDelegate.swift +// +// +// Created by MainasuK on 2022/10/28. +// + +import os.log +import UIKit +import MetaTextKit +import TwitterMeta +import MastodonMeta + +// MARK: - MetaTextDelegate +extension ComposeContentViewModel: MetaTextDelegate { + + public enum MetaTextViewKind: Int { + case none + case content + case contentWarning + } + + public func metaText( + _ metaText: MetaText, + processEditing textStorage: MetaTextStorage + ) -> MetaContent? { + let kind = MetaTextViewKind(rawValue: metaText.textView.tag) ?? .none + + switch kind { + case .none: + assertionFailure() + return nil + + case .content: + let textInput = textStorage.string + self.content = textInput + + let content = MastodonContent( + content: textInput, + emojis: [:] // TODO: emojiViewModel?.emojis.asDictionary ?? [:] + ) + let metaContent = MastodonMetaContent.convert(text: content) + return metaContent + + case .contentWarning: + let textInput = textStorage.string.replacingOccurrences(of: "\n", with: " ") + self.contentWarning = textInput + + let content = MastodonContent( + content: textInput, + emojis: [:] // emojiViewModel?.emojis.asDictionary ?? [:] + ) + let metaContent = MastodonMetaContent.convert(text: content) + return metaContent + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index cf67e8981..53db9ab30 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -6,12 +6,13 @@ // import os.log -import Foundation +import UIKit import Combine import CoreDataStack import MastodonCore import Meta import MastodonMeta +import MetaTextKit public final class ComposeContentViewModel: NSObject, ObservableObject { @@ -30,6 +31,42 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { @Published var viewLayoutFrame = ViewLayoutFrame() @Published var authContext: AuthContext + // output + + // limit + @Published public var maxTextInputLimit = 500 + + // content + public weak var contentMetaText: MetaText? { + didSet { +// guard let textView = contentMetaText?.textView else { return } +// customEmojiPickerInputViewModel.configure(textInput: textView) + } + } + @Published public var initialContent = "" + @Published public var content = "" + @Published public var contentWeightedLength = 0 + @Published public var isContentEmpty = true + @Published public var isContentValid = true + @Published public var isContentEditing = false + + // content warning + weak var contentWarningMetaText: MetaText? { + didSet { + //guard let textView = contentWarningMetaText?.textView else { return } + //customEmojiPickerInputViewModel.configure(textInput: textView) + } + } + @Published public var isContentWarningActive = false + @Published public var contentWarning = "" + @Published public var contentWarningWeightedLength = 0 // set 0 when not composing + @Published public var isContentWarningEditing = false + + // author + @Published var avatarURL: URL? + @Published var name: MetaContent = PlaintextMetaContent(string: "") + @Published var username: String = "" + // poll @Published var isPollActive = false @Published public var pollOptions: [PollComposeItem.Option] = { @@ -42,23 +79,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { @Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay @Published public var maxPollOptionLimit = 4 + // emoji @Published var isEmojiActive = false - @Published var isContentWarningActive = false - - // output - - // content - @Published public var initialContent = "" - @Published public var content = "" - @Published public var contentWeightedLength = 0 - @Published public var isContentEmpty = true - @Published public var isContentValid = true - @Published public var isContentEditing = false - - // author - @Published var avatarURL: URL? - @Published var name: MetaContent = PlaintextMetaContent(string: "") - @Published var username: String = "" // UI & UX @Published var replyToCellFrame: CGRect = .zero @@ -87,6 +109,26 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { self.username = user.acctWithDomain } .store(in: &disposeBag) + + // bind text + $content + .map { $0.count } + .assign(to: &$contentWeightedLength) + + Publishers.CombineLatest( + $contentWarning, + $isContentWarningActive + ) + .map { $1 ? $0.count : 0 } + .assign(to: &$contentWarningWeightedLength) + + Publishers.CombineLatest3( + $contentWeightedLength, + $contentWarningWeightedLength, + $maxTextInputLimit + ) + .map { $0 + $1 <= $2 } + .assign(to: &$isContentValid) } deinit { @@ -120,6 +162,72 @@ extension ComposeContentViewModel { } } +// MARK: - UITextViewDelegate +extension ComposeContentViewModel: UITextViewDelegate { + public func textViewDidBeginEditing(_ textView: UITextView) { + switch textView { + case contentMetaText?.textView: + isContentEditing = true + case contentWarningMetaText?.textView: + isContentWarningEditing = true + default: + break + } + } + + public func textViewDidEndEditing(_ textView: UITextView) { + switch textView { + case contentMetaText?.textView: + isContentEditing = false + case contentWarningMetaText?.textView: + isContentWarningEditing = false + default: + break + } + } + + public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + switch textView { + case contentMetaText?.textView: + return true + case contentWarningMetaText?.textView: + let isReturn = text == "\n" + if isReturn { + setContentTextViewFirstResponderIfNeeds() + } + return !isReturn + default: + assertionFailure() + return true + } + } + + func insertContentText(text: String) { + guard let contentMetaText = self.contentMetaText else { return } + // FIXME: smart prefix and suffix + let string = contentMetaText.textStorage.string + let isEmpty = string.isEmpty + let hasPrefix = string.hasPrefix(" ") + if hasPrefix || isEmpty { + contentMetaText.textView.insertText(text) + } else { + contentMetaText.textView.insertText(" " + text) + } + } + + func setContentTextViewFirstResponderIfNeeds() { + guard let contentMetaText = self.contentMetaText else { return } + guard !contentMetaText.textView.isFirstResponder else { return } + contentMetaText.textView.becomeFirstResponder() + } + + func setContentWarningTextViewFirstResponderIfNeeds() { + guard let contentWarningMetaText = self.contentWarningMetaText else { return } + guard !contentWarningMetaText.textView.isFirstResponder else { return } + contentWarningMetaText.textView.becomeFirstResponder() + } +} + // MARK: - DeleteBackwardResponseTextFieldRelayDelegate extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift index e04648c09..4a34c77d4 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift @@ -27,6 +27,10 @@ extension ComposeContentToolbarView { @Published var isEmojiActive = false @Published var isContentWarningActive = false + @Published public var maxTextInputLimit = 500 + @Published public var contentWeightedLength = 0 + @Published public var contentWarningWeightedLength = 0 + // output init(delegate: ComposeContentToolbarViewDelegate) { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift index 732e3b509..52026c636 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift @@ -74,8 +74,18 @@ struct ComposeContentToolbarView: View { } } Spacer() - Text("Hello") - .font(.system(size: 16, weight: .regular)) + let count: Int = { + if viewModel.isContentWarningActive { + return viewModel.contentWeightedLength + viewModel.contentWarningWeightedLength + } else { + return viewModel.contentWeightedLength + } + }() + let remains = viewModel.maxTextInputLimit - count + let isOverflow = remains < 0 + Text("\(remains)") + .foregroundColor(Color(isOverflow ? UIColor.systemRed : UIColor.secondaryLabel)) + .font(.system(size: isOverflow ? 18 : 16, weight: isOverflow ? .medium : .regular)) } .padding(.leading, 4) // 4 + 12 = 16 .padding(.trailing, 16) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift index 4aeb65689..25584848a 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift @@ -7,8 +7,10 @@ import os.log import SwiftUI +import MastodonAsset import MastodonCore import MastodonLocalization +import Stripes public struct ComposeContentView: View { @@ -22,14 +24,69 @@ public struct ComposeContentView: View { public var body: some View { VStack(spacing: .zero) { Group { + // content warning + if viewModel.isContentWarningActive { + MetaTextViewRepresentable( + string: $viewModel.contentWarning, + width: viewModel.viewLayoutFrame.layoutFrame.width - ComposeContentView.margin * 2, + configurationHandler: { metaText in + viewModel.contentWarningMetaText = metaText + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = UIColor.secondaryLabel + return NSAttributedString( + string: L10n.Scene.Compose.contentInputPlaceholder, + attributes: attributes + ) + }() + metaText.textView.returnKeyType = .next + metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.contentWarning.rawValue + metaText.textView.delegate = viewModel + metaText.delegate = viewModel + } + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, ComposeContentView.margin) + .background( + Color(UIColor.systemBackground) + .overlay( + HStack { + Stripes(config: StripesConfig( + background: Color.yellow, + foreground: Color.black, + degrees: 45, + barWidth: 2.5, + barSpacing: 3.5 + )) + .frame(width: ComposeContentView.margin * 0.5) + .frame(maxHeight: .infinity) + .id(UUID()) + Spacer() + Stripes(config: StripesConfig( + background: Color.yellow, + foreground: Color.black, + degrees: 45, + barWidth: 2.5, + barSpacing: 3.5 + )) + .frame(width: ComposeContentView.margin * 0.5) + .frame(maxHeight: .infinity) + .scaleEffect(x: -1, y: 1, anchor: .center) + .id(UUID()) + } + ) + ) + } // end if viewModel.isContentWarningActive // author authorView .padding(.top, 14) + .padding(.horizontal, ComposeContentView.margin) // content editor MetaTextViewRepresentable( string: $viewModel.content, width: viewModel.viewLayoutFrame.layoutFrame.width - ComposeContentView.margin * 2, configurationHandler: { metaText in + viewModel.contentMetaText = metaText metaText.textView.attributedPlaceholder = { var attributes = metaText.textAttributes attributes[.foregroundColor] = UIColor.secondaryLabel @@ -39,16 +96,18 @@ public struct ComposeContentView: View { ) }() metaText.textView.keyboardType = .twitter - // metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue - // metaText.textView.delegate = viewModel - // metaText.delegate = viewModel + metaText.textView.tag = ComposeContentViewModel.MetaTextViewKind.content.rawValue + metaText.textView.delegate = viewModel + metaText.delegate = viewModel metaText.textView.becomeFirstResponder() } ) .frame(minHeight: 100) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, ComposeContentView.margin) // poll pollView + .padding(.horizontal, ComposeContentView.margin) } .background( GeometryReader { proxy in @@ -65,8 +124,6 @@ public struct ComposeContentView: View { ) Spacer() } // end VStack - .padding(.horizontal, ComposeContentView.margin) -// .frame(alignment: .top) } // end body }