mastodon-ios/Mastodon/Scene/Compose/ComposeViewModel+DataSource...

515 lines
21 KiB
Swift

//
// ComposeViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import CoreDataStack
import MetaTextKit
import MastodonMeta
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonSDK
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<CustomEmojiPickerSection, CustomEmojiPickerItem>()
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<ComposeStatusPollSection, ComposeStatusPollItem>()
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<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>()
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)
}
}