// // ComposeViewModel+Diffable.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. // import os.log import UIKit import Combine import CoreDataStack import MastodonSDK import MastodonMeta import MetaTextKit import MastodonAsset import MastodonLocalization extension ComposeViewModel { func setupDataSource( tableView: UITableView, metaTextDelegate: MetaTextDelegate, metaTextViewDelegate: UITextViewDelegate, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) { // UI bind() // content bind(cell: composeStatusContentTableViewCell, tableView: tableView) composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate // attachment bind(cell: composeStatusAttachmentTableViewCell, tableView: tableView) composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate // poll bind(cell: composeStatusPollTableViewCell, tableView: tableView) composeStatusPollTableViewCell.delegate = self composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate // setup data source tableView.dataSource = self } func setupCustomEmojiPickerDiffableDataSource( for collectionView: UICollectionView, dependency: NeedsDependency ) { let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource( for: collectionView, dependency: dependency ) self.customEmojiPickerDiffableDataSource = diffableDataSource let _domain = customEmojiViewModel?.domain customEmojiViewModel?.emojis .receive(on: DispatchQueue.main) .sink { [weak self, weak diffableDataSource] emojis in guard let _ = self else { return } guard let diffableDataSource = diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() let domain = _domain?.uppercased() ?? " " let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain) snapshot.appendSections([customEmojiSection]) let items: [CustomEmojiPickerItem] = { var items = [CustomEmojiPickerItem]() for emoji in emojis where emoji.visibleInPicker { let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji) let item = CustomEmojiPickerItem.emoji(attribute: attribute) items.append(item) } return items }() snapshot.appendItems(items, toSection: customEmojiSection) diffableDataSource.apply(snapshot) } .store(in: &disposeBag) } } // MARK: - UITableViewDataSource extension ComposeViewModel: UITableViewDataSource { enum Section: CaseIterable { case repliedTo case status case attachment case poll } func numberOfSections(in tableView: UITableView) -> Int { return Section.allCases.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section.allCases[section] { case .repliedTo: switch composeKind { case .reply: return 1 default: return 0 } case .status: return 1 case .attachment: return 1 case .poll: return 1 } } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section.allCases[indexPath.section] { case .repliedTo: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell guard case let .reply(record) = composeKind else { return cell } // bind frame publisher cell.framePublisher .receive(on: DispatchQueue.main) .assign(to: \.repliedToCellFrame, on: self) .store(in: &cell.disposeBag) // set initial width if cell.statusView.frame.width == .zero { cell.statusView.frame.size.width = tableView.frame.width } // configure status context.managedObjectContext.performAndWait { guard let replyTo = record.object(in: context.managedObjectContext) else { return } cell.statusView.configure(status: replyTo) } return cell case .status: return composeStatusContentTableViewCell case .attachment: return composeStatusAttachmentTableViewCell case .poll: return composeStatusPollTableViewCell } } } // MARK: - ComposeStatusPollTableViewCellDelegate extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) self.pollOptionAttributes = options } } extension ComposeViewModel { private func bind() { $isCustomEmojiComposing .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .store(in: &disposeBag) $isContentWarningComposing .assign(to: \.isContentWarningComposing, on: composeStatusAttribute) .store(in: &disposeBag) // bind compose toolbar UI state Publishers.CombineLatest( $isPollComposing, $attachmentServices ) .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in guard let self = self else { return } let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments let shouldPollDisable = attachmentServices.count > 0 self.isMediaToolbarButtonEnabled = !shouldMediaDisable self.isPollToolbarButtonEnabled = !shouldPollDisable }) .store(in: &disposeBag) // calculate `Idempotency-Key` let content = Publishers.CombineLatest3( composeStatusAttribute.$isContentWarningComposing, composeStatusAttribute.$contentWarningContent, composeStatusAttribute.$composeContent ) .map { isContentWarningComposing, contentWarningContent, composeContent -> String in if isContentWarningComposing { return contentWarningContent + (composeContent ?? "") } else { return composeContent ?? "" } } let attachmentIDs = $attachmentServices.map { attachments -> String in let attachmentIDs = attachments.compactMap { $0.attachment.value?.id } return attachmentIDs.joined(separator: ",") } let pollOptionsAndDuration = Publishers.CombineLatest3( $isPollComposing, $pollOptionAttributes, pollExpiresOptionAttribute.expiresOption ) .map { isPollComposing, pollOptionAttributes, expiresOption -> String in guard isPollComposing else { return "" } let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",") return pollOptions + expiresOption.rawValue } Publishers.CombineLatest4( content, attachmentIDs, pollOptionsAndDuration, $selectedStatusVisibility ) .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in var hasher = Hasher() hasher.combine(content) hasher.combine(attachmentIDs) hasher.combine(pollOptionsAndDuration) hasher.combine(selectedStatusVisibility.visibility.rawValue) let hashValue = hasher.finalize() return "\(hashValue)" } .assign(to: \.value, on: idempotencyKey) .store(in: &disposeBag) // bind modal dismiss state composeStatusAttribute.$composeContent .receive(on: DispatchQueue.main) .map { [weak self] content in let content = content ?? "" if content.isEmpty { return true } // if preInsertedContent plus a space is equal to the content, simply dismiss the modal if let preInsertedContent = self?.preInsertedContent { return content == preInsertedContent } return false } .assign(to: &$shouldDismiss) // bind compose bar button item UI state let isComposeContentEmpty = composeStatusAttribute.$composeContent .map { ($0 ?? "").isEmpty } let isComposeContentValid = $characterCount .compactMap { [weak self] characterCount -> Bool in guard let self = self else { return characterCount <= 500 } return characterCount <= self.composeContentLimit } 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, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess ) .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in if isMediaEmpty { return isComposeContentValid && !isComposeContentEmpty } else { return isComposeContentValid && isMediaUploadAllSuccess } } .eraseToAnyPublisher() let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( isComposeContentEmpty, isComposeContentValid, $isPollComposing, isPollAttributeAllValid ) .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: &$isPublishBarButtonItemEnabled) } } extension ComposeViewModel { private func bind( cell: ComposeStatusContentTableViewCell, tableView: UITableView ) { // bind status content character count Publishers.CombineLatest3( composeStatusAttribute.$composeContent, composeStatusAttribute.$isContentWarningComposing, composeStatusAttribute.$contentWarningContent ) .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in let composeContent = composeContent ?? "" var count = composeContent.count if isContentWarningComposing { count += contentWarningContent.count } return count } .assign(to: &$characterCount) // bind content warning composeStatusAttribute.$isContentWarningComposing .receive(on: DispatchQueue.main) .sink { [weak cell, weak tableView] isContentWarningComposing in guard let cell = cell else { return } guard let tableView = tableView else { return } // self size input cell cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing cell.statusContentWarningEditorView.alpha = 0 UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { cell.statusContentWarningEditorView.alpha = 1 tableView.beginUpdates() tableView.endUpdates() } completion: { _ in // do nothing } } .store(in: &disposeBag) cell.contentWarningContent .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak tableView, weak self] text in guard let self = self else { return } // bind input data self.composeStatusAttribute.contentWarningContent = text // self size input cell guard let tableView = tableView else { return } UIView.performWithoutAnimation { tableView.beginUpdates() tableView.endUpdates() } } .store(in: &cell.disposeBag) // configure custom emoji picker ComposeStatusSection.configureCustomEmojiPicker( viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &disposeBag ) ComposeStatusSection.configureCustomEmojiPicker( viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &disposeBag ) } } extension ComposeViewModel { private func bind( cell: ComposeStatusPollTableViewCell, tableView: UITableView ) { Publishers.CombineLatest( $isPollComposing, $pollOptionAttributes ) .receive(on: DispatchQueue.main) .sink { [weak self] isPollComposing, pollOptionAttributes in guard let self = self else { return } guard self.isViewAppeared else { return } let cell = self.composeStatusPollTableViewCell guard let dataSource = cell.dataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) var items: [ComposeStatusPollItem] = [] if isPollComposing { for attribute in pollOptionAttributes { items.append(.pollOption(attribute: attribute)) } if pollOptionAttributes.count < self.maxPollOptions { items.append(.pollOptionAppendEntry) } items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)) } snapshot.appendItems(items, toSection: .main) tableView.performBatchUpdates { if #available(iOS 15.0, *) { dataSource.apply(snapshot, animatingDifferences: false) } else { dataSource.apply(snapshot, animatingDifferences: true) } } } .store(in: &disposeBag) // bind delegate $pollOptionAttributes .sink { [weak self] pollAttributes in guard let self = self else { return } pollAttributes.forEach { $0.delegate = self } } .store(in: &disposeBag) } } extension ComposeViewModel { private func bind( cell: ComposeStatusAttachmentTableViewCell, tableView: UITableView ) { cell.collectionViewHeightDidUpdate .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let _ = self else { return } tableView.beginUpdates() tableView.endUpdates() } .store(in: &disposeBag) $attachmentServices .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices in guard let self = self else { return } guard self.isViewAppeared else { return } let cell = self.composeStatusAttachmentTableViewCell guard let dataSource = cell.dataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } snapshot.appendItems(items, toSection: .main) if #available(iOS 15.0, *) { dataSource.applySnapshotUsingReloadData(snapshot) } else { dataSource.apply(snapshot, animatingDifferences: false) } } .store(in: &disposeBag) // setup attribute updater $attachmentServices .receive(on: DispatchQueue.main) .debounce(for: 0.3, scheduler: DispatchQueue.main) .sink { attachmentServices in // 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.Processing { 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) } }