forked from zelo72/mastodon-ios
388 lines
15 KiB
Swift
388 lines
15 KiB
Swift
//
|
|
// ComposeViewModel.swift
|
|
// Mastodon
|
|
//
|
|
// Created by MainasuK Cirno on 2021-3-11.
|
|
//
|
|
|
|
import os.log
|
|
import UIKit
|
|
import Combine
|
|
import CoreData
|
|
import CoreDataStack
|
|
import GameplayKit
|
|
import MastodonSDK
|
|
import MastodonAsset
|
|
import MastodonLocalization
|
|
import MastodonMeta
|
|
import MastodonUI
|
|
|
|
final class ComposeViewModel: NSObject {
|
|
|
|
let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel")
|
|
|
|
var disposeBag = Set<AnyCancellable>()
|
|
|
|
let id = UUID()
|
|
|
|
// input
|
|
let context: AppContext
|
|
let composeKind: ComposeStatusSection.ComposeKind
|
|
let authenticationBox: MastodonAuthenticationBox
|
|
|
|
|
|
@Published var isPollComposing = false
|
|
@Published var isCustomEmojiComposing = false
|
|
@Published var isContentWarningComposing = false
|
|
|
|
@Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
|
|
@Published var repliedToCellFrame: CGRect = .zero
|
|
@Published var autoCompleteRetryLayoutTimes = 0
|
|
@Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
|
|
|
|
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
|
|
var isViewAppeared = false
|
|
|
|
// output
|
|
let instanceConfiguration: Mastodon.Entity.Instance.Configuration?
|
|
var composeContentLimit: Int {
|
|
guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 }
|
|
return max(1, maxCharacters)
|
|
}
|
|
var maxMediaAttachments: Int {
|
|
guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else {
|
|
return 4
|
|
}
|
|
// FIXME: update timeline media preview UI
|
|
return min(4, max(1, maxMediaAttachments))
|
|
// return max(1, maxMediaAttachments)
|
|
}
|
|
var maxPollOptions: Int {
|
|
guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 }
|
|
return max(2, maxOptions)
|
|
}
|
|
|
|
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
|
|
let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
|
|
let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
|
|
let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
|
|
|
|
// var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>?
|
|
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
|
|
private(set) lazy var publishStateMachine: GKStateMachine = {
|
|
// exclude timeline middle fetcher state
|
|
let stateMachine = GKStateMachine(states: [
|
|
PublishState.Initial(viewModel: self),
|
|
PublishState.Publishing(viewModel: self),
|
|
PublishState.Fail(viewModel: self),
|
|
PublishState.Discard(viewModel: self),
|
|
PublishState.Finish(viewModel: self),
|
|
])
|
|
stateMachine.enter(PublishState.Initial.self)
|
|
return stateMachine
|
|
}()
|
|
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
|
|
private(set) var publishDate = Date() // update it when enter Publishing state
|
|
|
|
// TODO: group post material into Hashable class
|
|
var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
|
|
|
|
// UI & UX
|
|
@Published var title: String
|
|
@Published var shouldDismiss = true
|
|
@Published var isPublishBarButtonItemEnabled = false
|
|
@Published var isMediaToolbarButtonEnabled = true
|
|
@Published var isPollToolbarButtonEnabled = true
|
|
@Published var characterCount = 0
|
|
@Published var collectionViewState: CollectionViewState = .fold
|
|
|
|
// for hashtag: "#<hashtag> "
|
|
// for mention: "@<mention> "
|
|
var preInsertedContent: String?
|
|
|
|
// custom emojis
|
|
let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
|
|
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
|
|
@Published var isLoadingCustomEmoji = false
|
|
|
|
// attachment
|
|
@Published var attachmentServices: [MastodonAttachmentService] = []
|
|
|
|
// polls
|
|
@Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = []
|
|
let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
|
|
|
|
init(
|
|
context: AppContext,
|
|
composeKind: ComposeStatusSection.ComposeKind,
|
|
authenticationBox: MastodonAuthenticationBox
|
|
) {
|
|
self.context = context
|
|
self.composeKind = composeKind
|
|
self.authenticationBox = authenticationBox
|
|
self.title = {
|
|
switch composeKind {
|
|
case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
|
|
case .reply: return L10n.Scene.Compose.Title.newReply
|
|
}
|
|
}()
|
|
self.selectedStatusVisibility = {
|
|
// default private when user locked
|
|
var visibility: ComposeToolbarView.VisibilitySelectionType = {
|
|
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value,
|
|
let author = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
|
|
else {
|
|
return .public
|
|
}
|
|
return author.locked ? .private : .public
|
|
}()
|
|
// set visibility for reply post
|
|
switch composeKind {
|
|
case .reply(let record):
|
|
context.managedObjectContext.performAndWait {
|
|
guard let status = record.object(in: context.managedObjectContext) else {
|
|
assertionFailure()
|
|
return
|
|
}
|
|
let repliedStatusVisibility = status.visibility
|
|
switch repliedStatusVisibility {
|
|
case .public, .unlisted:
|
|
// keep default
|
|
break
|
|
case .private:
|
|
visibility = .private
|
|
case .direct:
|
|
visibility = .direct
|
|
case ._other:
|
|
assertionFailure()
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
return visibility
|
|
}()
|
|
// set limit
|
|
self.instanceConfiguration = {
|
|
var configuration: Mastodon.Entity.Instance.Configuration? = nil
|
|
context.managedObjectContext.performAndWait {
|
|
guard let authentication = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)
|
|
else {
|
|
return
|
|
}
|
|
configuration = authentication.instance?.configuration
|
|
}
|
|
return configuration
|
|
}()
|
|
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authenticationBox.domain)
|
|
super.init()
|
|
// end init
|
|
|
|
setup(cell: composeStatusContentTableViewCell)
|
|
}
|
|
|
|
deinit {
|
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
|
}
|
|
|
|
}
|
|
|
|
extension ComposeViewModel {
|
|
enum CollectionViewState {
|
|
case fold // snap to input
|
|
case expand // snap to reply
|
|
}
|
|
}
|
|
|
|
extension ComposeViewModel {
|
|
func createNewPollOptionIfPossible() {
|
|
guard pollOptionAttributes.count < maxPollOptions else { return }
|
|
|
|
let attribute = ComposeStatusPollItem.PollOptionAttribute()
|
|
pollOptionAttributes = pollOptionAttributes + [attribute]
|
|
}
|
|
|
|
func updatePublishDate() {
|
|
publishDate = Date()
|
|
}
|
|
}
|
|
|
|
extension ComposeViewModel {
|
|
|
|
enum AttachmentPrecondition: Error, LocalizedError {
|
|
case videoAttachWithPhoto
|
|
case moreThanOneVideo
|
|
|
|
var errorDescription: String? {
|
|
return L10n.Common.Alerts.PublishPostFailure.title
|
|
}
|
|
|
|
var failureReason: String? {
|
|
switch self {
|
|
case .videoAttachWithPhoto:
|
|
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
|
|
case .moreThanOneVideo:
|
|
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
|
|
}
|
|
}
|
|
}
|
|
|
|
// check exclusive limit:
|
|
// - up to 1 video
|
|
// - up to N photos
|
|
func checkAttachmentPrecondition() throws {
|
|
let attachmentServices = self.attachmentServices
|
|
guard !attachmentServices.isEmpty else { return }
|
|
var photoAttachmentServices: [MastodonAttachmentService] = []
|
|
var videoAttachmentServices: [MastodonAttachmentService] = []
|
|
attachmentServices.forEach { service in
|
|
guard let file = service.file.value else {
|
|
assertionFailure()
|
|
return
|
|
}
|
|
switch file {
|
|
case .jpeg, .png, .gif:
|
|
photoAttachmentServices.append(service)
|
|
case .other:
|
|
videoAttachmentServices.append(service)
|
|
}
|
|
}
|
|
|
|
if !videoAttachmentServices.isEmpty {
|
|
guard videoAttachmentServices.count == 1 else {
|
|
throw AttachmentPrecondition.moreThanOneVideo
|
|
}
|
|
guard photoAttachmentServices.isEmpty else {
|
|
throw AttachmentPrecondition.videoAttachWithPhoto
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - MastodonAttachmentServiceDelegate
|
|
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
|
|
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
|
|
// trigger new output event
|
|
attachmentServices = attachmentServices
|
|
}
|
|
}
|
|
|
|
// MARK: - ComposePollAttributeDelegate
|
|
extension ComposeViewModel: ComposePollAttributeDelegate {
|
|
func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
|
|
// trigger update
|
|
pollOptionAttributes = pollOptionAttributes
|
|
}
|
|
}
|
|
|
|
extension ComposeViewModel {
|
|
private func setup(
|
|
cell: ComposeStatusContentTableViewCell
|
|
) {
|
|
setupStatusHeader(cell: cell)
|
|
setupStatusAuthor(cell: cell)
|
|
setupStatusContent(cell: cell)
|
|
}
|
|
|
|
private func setupStatusHeader(
|
|
cell: ComposeStatusContentTableViewCell
|
|
) {
|
|
// configure header
|
|
let managedObjectContext = context.managedObjectContext
|
|
managedObjectContext.performAndWait {
|
|
guard case let .reply(record) = self.composeKind,
|
|
let replyTo = record.object(in: managedObjectContext)
|
|
else {
|
|
cell.statusView.viewModel.header = .none
|
|
return
|
|
}
|
|
|
|
let info: StatusView.ViewModel.Header.ReplyInfo
|
|
do {
|
|
let content = MastodonContent(
|
|
content: replyTo.author.displayNameWithFallback,
|
|
emojis: replyTo.author.emojis.asDictionary
|
|
)
|
|
let metaContent = try MastodonMetaContent.convert(document: content)
|
|
info = .init(header: metaContent)
|
|
} catch {
|
|
let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback)
|
|
info = .init(header: metaContent)
|
|
}
|
|
cell.statusView.viewModel.header = .reply(info: info)
|
|
}
|
|
}
|
|
|
|
private func setupStatusAuthor(
|
|
cell: ComposeStatusContentTableViewCell
|
|
) {
|
|
self.context.managedObjectContext.performAndWait {
|
|
guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return }
|
|
cell.statusView.configureAuthor(author: author)
|
|
}
|
|
}
|
|
|
|
private func setupStatusContent(
|
|
cell: ComposeStatusContentTableViewCell
|
|
) {
|
|
switch composeKind {
|
|
case .reply(let record):
|
|
context.managedObjectContext.performAndWait {
|
|
guard let status = record.object(in: context.managedObjectContext) else { return }
|
|
let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
|
|
|
|
var mentionAccts: [String] = []
|
|
if author?.id != status.author.id {
|
|
mentionAccts.append("@" + status.author.acct)
|
|
}
|
|
let mentions = status.mentions
|
|
.filter { author?.id != $0.id }
|
|
for mention in mentions {
|
|
let acct = "@" + mention.acct
|
|
guard !mentionAccts.contains(acct) else { continue }
|
|
mentionAccts.append(acct)
|
|
}
|
|
for acct in mentionAccts {
|
|
UITextChecker.learnWord(acct)
|
|
}
|
|
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
|
|
self.isContentWarningComposing = true
|
|
self.composeStatusAttribute.contentWarningContent = spoilerText
|
|
}
|
|
|
|
let initialComposeContent = mentionAccts.joined(separator: " ")
|
|
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
|
|
self.preInsertedContent = preInsertedContent
|
|
self.composeStatusAttribute.composeContent = preInsertedContent
|
|
}
|
|
case .hashtag(let hashtag):
|
|
let initialComposeContent = "#" + hashtag
|
|
UITextChecker.learnWord(initialComposeContent)
|
|
let preInsertedContent = initialComposeContent + " "
|
|
self.preInsertedContent = preInsertedContent
|
|
self.composeStatusAttribute.composeContent = preInsertedContent
|
|
case .mention(let record):
|
|
context.managedObjectContext.performAndWait {
|
|
guard let user = record.object(in: context.managedObjectContext) else { return }
|
|
let initialComposeContent = "@" + user.acct
|
|
UITextChecker.learnWord(initialComposeContent)
|
|
let preInsertedContent = initialComposeContent + " "
|
|
self.preInsertedContent = preInsertedContent
|
|
self.composeStatusAttribute.composeContent = preInsertedContent
|
|
}
|
|
case .post:
|
|
self.preInsertedContent = nil
|
|
}
|
|
|
|
// configure content warning
|
|
if let composeContent = composeStatusAttribute.composeContent {
|
|
cell.metaText.textView.text = composeContent
|
|
}
|
|
|
|
// configure content warning
|
|
cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent
|
|
}
|
|
}
|