// // 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() 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()) // 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? var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource? 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(nil) private(set) var publishDate = Date() // update it when enter Publishing state // TODO: group post material into Hashable class var idempotencyKey = CurrentValueSubject(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: "# " // for 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 } }