// // ComposeViewModel.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. // import UIKit import Combine import CoreData import CoreDataStack import GameplayKit final class ComposeViewModel { var disposeBag = Set() // input let context: AppContext let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let isPollComposing = CurrentValueSubject(false) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject // output //var diffableDataSource: UITableViewDiffableDataSource! var diffableDataSource: 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.Finish(viewModel: self), ]) stateMachine.enter(PublishState.Initial.self) return stateMachine }() // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) let isPublishBarButtonItemEnabled = CurrentValueSubject(false) let isMediaToolbarButtonEnabled = CurrentValueSubject(true) let isPollToolbarButtonEnabled = CurrentValueSubject(true) // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) // polls let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init // bind active authentication context.authenticationService.activeMastodonAuthentication .assign(to: \.value, on: activeAuthentication) .store(in: &disposeBag) context.authenticationService.activeMastodonAuthenticationBox .assign(to: \.value, on: activeAuthenticationBox) .store(in: &disposeBag) // bind avatar and names activeAuthentication .sink { [weak self] mastodonAuthentication in guard let self = self else { return } let mastodonUser = mastodonAuthentication?.user let username = mastodonUser?.username ?? " " self.composeStatusAttribute.avatarURL.value = mastodonUser?.avatarImageURL() self.composeStatusAttribute.displayName.value = { guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { return username } return displayName }() self.composeStatusAttribute.username.value = username } .store(in: &disposeBag) // bind compose bar button item UI state let isComposeContentEmpty = composeStatusAttribute.composeContent .map { ($0 ?? "").isEmpty } let isComposeContentValid = Just(true).eraseToAnyPublisher() let isMediaEmpty = attachmentServices .map { $0.isEmpty } let isMediaUploadAllSuccess = attachmentServices .map { services in services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } } let isPollAttributeAllValid = pollOptionAttributes .map { pollAttributes in pollAttributes.allSatisfy { attribute -> Bool in !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( isComposeContentEmpty.eraseToAnyPublisher(), isComposeContentValid.eraseToAnyPublisher(), isMediaEmpty.eraseToAnyPublisher(), isMediaUploadAllSuccess.eraseToAnyPublisher() ) .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in if isMediaEmpty { return isComposeContentValid && !isComposeContentEmpty } else { return isComposeContentValid && isMediaUploadAllSuccess } } .eraseToAnyPublisher() let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( isComposeContentEmpty.eraseToAnyPublisher(), isComposeContentValid.eraseToAnyPublisher(), isPollComposing.eraseToAnyPublisher(), isPollAttributeAllValid.eraseToAnyPublisher() ) .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in if isPollComposing { return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid } else { return isComposeContentValid && !isComposeContentEmpty } } .eraseToAnyPublisher() Publishers.CombineLatest( isPublishBarButtonItemEnabledPrecondition1, isPublishBarButtonItemEnabledPrecondition2 ) .map { $0 && $1 } .assign(to: \.value, on: isPublishBarButtonItemEnabled) .store(in: &disposeBag) // bind modal dismiss state composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) .map { content in let content = content ?? "" return content.isEmpty } .assign(to: \.value, on: shouldDismiss) .store(in: &disposeBag) // bind custom emojis context.authenticationService.activeMastodonAuthenticationBox .receive(on: DispatchQueue.main) .sink { [weak self] activeMastodonAuthenticationBox in guard let self = self else { return } guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } let domain = activeMastodonAuthenticationBox.domain // trigger dequeue to preload emojis self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) // bind snapshot Publishers.CombineLatest3( attachmentServices.eraseToAnyPublisher(), isPollComposing.eraseToAnyPublisher(), pollOptionAttributes.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } var snapshot = diffableDataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) var attachmentItems: [ComposeStatusItem] = [] for attachmentService in attachmentServices { let item = ComposeStatusItem.attachment(attachmentService: attachmentService) attachmentItems.append(item) } snapshot.appendItems(attachmentItems, toSection: .attachment) snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) if isPollComposing { var pollItems: [ComposeStatusItem] = [] for pollAttribute in pollAttributes { let item = ComposeStatusItem.pollOption(attribute: pollAttribute) pollItems.append(item) } snapshot.appendItems(pollItems, toSection: .poll) if pollAttributes.count < 4 { snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll) } snapshot.appendItems([ComposeStatusItem.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)], toSection: .poll) } diffableDataSource.apply(snapshot) // drive service upload state // make image upload in the queue for attachmentService in attachmentServices { // skip when prefix N task when task finish OR fail OR uploading guard let currentState = attachmentService.uploadStateMachine.currentState else { break } if currentState is MastodonAttachmentService.UploadState.Fail { continue } if currentState is MastodonAttachmentService.UploadState.Finish { continue } if currentState is MastodonAttachmentService.UploadState.Uploading { break } // trigger uploading one by one if currentState is MastodonAttachmentService.UploadState.Initial { attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) break } } } .store(in: &disposeBag) // bind delegate attachmentServices .sink { [weak self] attachmentServices in guard let self = self else { return } attachmentServices.forEach { $0.delegate = self } } .store(in: &disposeBag) pollOptionAttributes .sink { [weak self] pollAttributes in guard let self = self else { return } pollAttributes.forEach { $0.delegate = self } } .store(in: &disposeBag) // bind compose toolbar UI state Publishers.CombineLatest( isPollComposing.eraseToAnyPublisher(), attachmentServices.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in guard let self = self else { return } let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4 let shouldPollDisable = attachmentServices.count > 0 self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable self.isPollToolbarButtonEnabled.value = !shouldPollDisable }) .store(in: &disposeBag) } } extension ComposeViewModel { func createNewPollOptionIfPossible() { guard pollOptionAttributes.value.count < 4 else { return } let attribute = ComposeStatusItem.ComposePollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] } } // MARK: - MastodonAttachmentServiceDelegate extension ComposeViewModel: MastodonAttachmentServiceDelegate { func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { // trigger new output event attachmentServices.value = attachmentServices.value } } // MARK: - ComposePollAttributeDelegate extension ComposeViewModel: ComposePollAttributeDelegate { func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { // trigger update pollOptionAttributes.value = pollOptionAttributes.value } }